commit
e163684b69
362 changed files with 45640 additions and 5650 deletions
7
.github/SECURITY.md
vendored
7
.github/SECURITY.md
vendored
|
|
@ -34,10 +34,11 @@ Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compo
|
||||||
|
|
||||||
## IPC & Input Validation
|
## IPC & Input Validation
|
||||||
|
|
||||||
- All IPC handlers validate inputs with strict path containment checks
|
- Electron IPC and standalone HTTP handlers validate IDs, paths, and payloads at the boundary
|
||||||
- File reads are constrained to the project root and `~/.claude/`
|
- Project editing and write operations are constrained to the selected project root
|
||||||
|
- Read-only discovery may access local Claude data under `~/.claude/` and app-owned state paths when needed
|
||||||
- Path traversal attacks are blocked
|
- Path traversal attacks are blocked
|
||||||
- Sensitive credential paths are rejected
|
- Sensitive config and credential-like paths are rejected or treated as protected targets
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
|
|
|
||||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Agent Navigation
|
||||||
|
|
||||||
|
This file is a navigation layer for architecture and implementation guidance.
|
||||||
|
|
||||||
|
Start here:
|
||||||
|
- Repo overview and commands: [README.md](README.md)
|
||||||
|
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
|
||||||
|
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||||
|
|
||||||
|
For new features:
|
||||||
|
- Default home for medium and large features: `src/features/<feature-name>/`
|
||||||
|
- Reference implementation: `src/features/recent-projects`
|
||||||
|
- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md)
|
||||||
|
|
||||||
|
Do not treat this file as a second source of truth.
|
||||||
|
Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md).
|
||||||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -57,8 +57,21 @@ Use path aliases for imports:
|
||||||
- `@preload/*` → `src/preload/*`
|
- `@preload/*` → `src/preload/*`
|
||||||
|
|
||||||
## Features Architecture
|
## Features Architecture
|
||||||
**All new features MUST be created in `src/renderer/features/<feature-name>/`.**
|
**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).**
|
||||||
See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns.
|
|
||||||
|
Default location:
|
||||||
|
- `src/features/<feature-name>/`
|
||||||
|
|
||||||
|
Reference implementation:
|
||||||
|
- `src/features/recent-projects`
|
||||||
|
|
||||||
|
Feature-local guidance:
|
||||||
|
- `src/features/CLAUDE.md`
|
||||||
|
|
||||||
|
Legacy note:
|
||||||
|
- `src/renderer/features/*` still exists for older renderer-only slices
|
||||||
|
- do not use `src/renderer/features/*` as the default for new cross-process features
|
||||||
|
- thin renderer-only slices may still stay local when they do not need `core/`, transport wiring, or multi-process boundaries
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
~/.claude/projects/{encoded-path}/*.jsonl - Session files
|
~/.claude/projects/{encoded-path}/*.jsonl - Session files
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -10,7 +10,7 @@
|
||||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Claude Agent Teams UI</a></h1>
|
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams UI</a></h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents.</sub>
|
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/9d502887-7e28-4a11-aedd-3bd45fdfb0d2" />
|
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/9d502887-7e28-4a11-aedd-3bd45fdfb0d2" />
|
||||||
|
|
@ -91,6 +91,7 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Table of contents](#table-of-contents)
|
- [Table of contents](#table-of-contents)
|
||||||
- [What is this](#what-is-this)
|
- [What is this](#what-is-this)
|
||||||
|
- [Developer architecture docs](#developer-architecture-docs)
|
||||||
- [Comparison](#comparison)
|
- [Comparison](#comparison)
|
||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
|
|
@ -107,15 +108,14 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
|
||||||
|
|
||||||
A local orchestration layer for AI agent teams across Claude and Codex.
|
A local orchestration layer for AI agent teams across Claude and Codex.
|
||||||
|
|
||||||
- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions/logins or API keys where supported
|
- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions or API keys
|
||||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
|
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
|
||||||
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
|
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
|
||||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||||
- **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned
|
- **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned
|
||||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
- **Task-specific logs and messages** — clearly see agent/runtime logs (tools), actions and messages in isolation for each individual task, making it easy to trace what happened for any assignment
|
||||||
- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
|
|
||||||
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
||||||
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
||||||
- **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs
|
- **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs
|
||||||
|
|
@ -156,9 +156,18 @@ A local orchestration layer for AI agent teams across Claude and Codex.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Developer architecture docs
|
||||||
|
|
||||||
|
For feature architecture and implementation guidance:
|
||||||
|
|
||||||
|
- Canonical standard - [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||||
|
- Repo working instructions - [CLAUDE.md](CLAUDE.md)
|
||||||
|
- Feature root guidance - [src/features/README.md](src/features/README.md)
|
||||||
|
- Reference implementation - `src/features/recent-projects`
|
||||||
|
|
||||||
## Comparison
|
## Comparison
|
||||||
|
|
||||||
| Feature | Claude Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI |
|
| Feature | Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ |
|
| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ |
|
||||||
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) |
|
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) |
|
||||||
|
|
@ -296,7 +305,7 @@ pnpm dist # macOS + Windows + Linux
|
||||||
|
|
||||||
- [ ] Planning mode to organize agent plans before execution
|
- [ ] Planning mode to organize agent plans before execution
|
||||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
- [ ] Support more models/providers (including local) e.g OpenCode (with many providers)
|
||||||
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
|
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
|
||||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||||
|
|
@ -307,6 +316,8 @@ pnpm dist # macOS + Windows + Linux
|
||||||
- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow
|
- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow
|
||||||
- [ ] Custom kanban columns
|
- [ ] Custom kanban columns
|
||||||
- [ ] Run terminal commands
|
- [ ] Run terminal commands
|
||||||
|
- [ ] Monitor agents processes/stats
|
||||||
|
- [ ] Reusable agents with SOUL.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -316,7 +327,7 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Pleas
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](.github/SECURITY.md) for details.
|
IPC and standalone HTTP handlers validate IDs, paths, and payload shape at the boundary. Project editing and write operations are constrained to the selected project root, while read-only discovery also accesses local Claude data under `~/.claude/` and app-owned state paths when required. Path traversal and sensitive config/credential targets are blocked. See [SECURITY.md](.github/SECURITY.md) for details.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
314
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal file
314
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
# Feature Architecture Standard
|
||||||
|
|
||||||
|
**Status**: team standard
|
||||||
|
**Reference implementation**: `src/features/recent-projects`
|
||||||
|
|
||||||
|
This document defines the default architecture for medium and large features in this repository.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- keep business rules isolated from Electron-specific runtime details
|
||||||
|
- make features easier to scale, test, and review
|
||||||
|
- keep renderer code closer to browser and Tauri portability
|
||||||
|
- enforce architecture with tooling, not only with code review comments
|
||||||
|
|
||||||
|
## Canonical Template
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/features/<feature-name>/
|
||||||
|
contracts/
|
||||||
|
core/
|
||||||
|
domain/
|
||||||
|
application/
|
||||||
|
main/
|
||||||
|
composition/
|
||||||
|
adapters/
|
||||||
|
input/
|
||||||
|
output/
|
||||||
|
infrastructure/
|
||||||
|
preload/
|
||||||
|
renderer/
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this template by default when a feature:
|
||||||
|
|
||||||
|
- spans more than one process boundary
|
||||||
|
- introduces its own use case or business policy
|
||||||
|
- needs its own transport bridge or integration surface
|
||||||
|
- is expected to grow with new providers, sources, or presentation flows
|
||||||
|
|
||||||
|
## Layer Responsibilities
|
||||||
|
|
||||||
|
### `contracts/`
|
||||||
|
|
||||||
|
Cross-process public API for the feature.
|
||||||
|
|
||||||
|
Allowed content:
|
||||||
|
|
||||||
|
- DTOs
|
||||||
|
- API fragment types
|
||||||
|
- IPC or route constants
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- store access
|
||||||
|
- Electron APIs
|
||||||
|
- business orchestration
|
||||||
|
|
||||||
|
### `core/domain/`
|
||||||
|
|
||||||
|
Pure business rules and invariants.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- merge policies
|
||||||
|
- provider-agnostic models
|
||||||
|
- selection rules
|
||||||
|
- dedupe logic
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- infrastructure access
|
||||||
|
- framework access
|
||||||
|
- side effects
|
||||||
|
|
||||||
|
### `core/application/`
|
||||||
|
|
||||||
|
Use cases and ports.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- orchestration flow
|
||||||
|
- output ports
|
||||||
|
- cache ports
|
||||||
|
- source ports
|
||||||
|
- response models
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- Electron, Fastify, React, Zustand, child processes
|
||||||
|
|
||||||
|
### `main/composition/`
|
||||||
|
|
||||||
|
Feature composition root in the main process.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- instantiate infrastructure
|
||||||
|
- wire adapters
|
||||||
|
- wire use cases
|
||||||
|
- expose a small facade to app shell entrypoints
|
||||||
|
|
||||||
|
### `main/adapters/input/`
|
||||||
|
|
||||||
|
Driving adapters for the main process.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- IPC handlers
|
||||||
|
- HTTP route registration
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- translate transport input into use case calls
|
||||||
|
- keep transport concerns out of use cases
|
||||||
|
|
||||||
|
### `main/adapters/output/`
|
||||||
|
|
||||||
|
Driven adapters that implement application ports.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- presenters
|
||||||
|
- source adapters
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- translate between external data and core models
|
||||||
|
- stay thin around infrastructure helpers
|
||||||
|
|
||||||
|
### `main/infrastructure/`
|
||||||
|
|
||||||
|
Concrete technical implementation details.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- file system adapters
|
||||||
|
- JSON-RPC transport clients
|
||||||
|
- binary discovery
|
||||||
|
- cache implementation
|
||||||
|
- git identity helpers
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- know about runtime, process, OS, or protocol details
|
||||||
|
|
||||||
|
### `preload/`
|
||||||
|
|
||||||
|
Thin transport bridge between renderer and main.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- expose a feature API fragment
|
||||||
|
- depend on `contracts/`
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- main composition code
|
||||||
|
- renderer logic
|
||||||
|
|
||||||
|
### `renderer/`
|
||||||
|
|
||||||
|
Feature presentation and interaction.
|
||||||
|
|
||||||
|
Recommended structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
renderer/
|
||||||
|
index.ts
|
||||||
|
adapters/
|
||||||
|
hooks/
|
||||||
|
ui/
|
||||||
|
utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- `ui/` renders
|
||||||
|
- `hooks/` orchestrate interaction and transport usage
|
||||||
|
- `adapters/` transform DTOs into view models
|
||||||
|
- `utils/` contain small pure renderer helpers
|
||||||
|
|
||||||
|
## Import Rules
|
||||||
|
|
||||||
|
### Public entrypoints only
|
||||||
|
|
||||||
|
Outside the feature, import only:
|
||||||
|
|
||||||
|
- `@features/<feature>/contracts`
|
||||||
|
- `@features/<feature>/main`
|
||||||
|
- `@features/<feature>/preload`
|
||||||
|
- `@features/<feature>/renderer`
|
||||||
|
|
||||||
|
Do not deep-import feature internals from app shell or from other features.
|
||||||
|
|
||||||
|
### Core isolation
|
||||||
|
|
||||||
|
`core/domain` must not import:
|
||||||
|
|
||||||
|
- `@main/*`
|
||||||
|
- `@renderer/*`
|
||||||
|
- `@preload/*`
|
||||||
|
- adapters
|
||||||
|
- infrastructure
|
||||||
|
- Electron APIs
|
||||||
|
- Fastify
|
||||||
|
- child process modules
|
||||||
|
|
||||||
|
`core/application` must not import:
|
||||||
|
|
||||||
|
- `main/*`
|
||||||
|
- `renderer/*`
|
||||||
|
- Electron APIs
|
||||||
|
- Fastify
|
||||||
|
- child process modules
|
||||||
|
|
||||||
|
### UI isolation
|
||||||
|
|
||||||
|
`renderer/ui` must not import:
|
||||||
|
|
||||||
|
- `@renderer/api`
|
||||||
|
- `@renderer/store`
|
||||||
|
- `@main/*`
|
||||||
|
- Electron APIs
|
||||||
|
|
||||||
|
Push transport and store access into feature hooks or adapters.
|
||||||
|
|
||||||
|
## Browser and Tauri Friendly Guidance
|
||||||
|
|
||||||
|
The default transport direction should be:
|
||||||
|
|
||||||
|
`renderer -> feature contracts -> app api abstraction -> preload/http adapter`
|
||||||
|
|
||||||
|
This keeps renderer code closer to:
|
||||||
|
|
||||||
|
- browser mode through HTTP adapters
|
||||||
|
- a future Tauri bridge
|
||||||
|
- alternative shells with minimal feature rewrites
|
||||||
|
|
||||||
|
To keep that path clean:
|
||||||
|
|
||||||
|
- never call `window.electronAPI` directly inside feature UI or hooks
|
||||||
|
- go through shared renderer API adapters
|
||||||
|
- keep Electron-specific concerns in `main/` and `preload/`
|
||||||
|
- keep business rules in `core/`
|
||||||
|
|
||||||
|
## When To Use The Full Slice
|
||||||
|
|
||||||
|
Use the full template when a feature has:
|
||||||
|
|
||||||
|
- its own business rules
|
||||||
|
- its own merge or filtering policy
|
||||||
|
- transport wiring
|
||||||
|
- more than one adapter
|
||||||
|
- a roadmap beyond a one-off screen tweak
|
||||||
|
|
||||||
|
## When A Thin Slice Is Enough
|
||||||
|
|
||||||
|
A smaller feature may skip `core/` and `preload/` when it is:
|
||||||
|
|
||||||
|
- purely presentational
|
||||||
|
- only reshaping already-owned data
|
||||||
|
- not adding a new use case
|
||||||
|
- not adding a new transport boundary
|
||||||
|
|
||||||
|
If the feature still owns meaningful pure semantics or projection rules, keep
|
||||||
|
`core/` and skip only the process layers you do not need.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `src/features/agent-graph` keeps `core/domain` and `renderer`, but does not add fake `main/` or `preload/` folders because the transport boundary lives elsewhere.
|
||||||
|
## Definition Of Done For A Reference Feature
|
||||||
|
|
||||||
|
A feature is reference-quality when:
|
||||||
|
|
||||||
|
- structure matches the canonical template
|
||||||
|
- core is side-effect free
|
||||||
|
- app shell imports only public entrypoints
|
||||||
|
- renderer UI is dumb and presentational
|
||||||
|
- at least the main domain and application rules are tested
|
||||||
|
- architecture is enforced by lint rules
|
||||||
|
- feature has a concise standard or plan doc if it introduces a new pattern
|
||||||
|
|
||||||
|
## Recommended Test Coverage
|
||||||
|
|
||||||
|
For medium and large features, cover at least:
|
||||||
|
|
||||||
|
- domain policy tests
|
||||||
|
- application use case tests
|
||||||
|
- critical renderer interaction utilities
|
||||||
|
- one adapter-level mapping test
|
||||||
|
|
||||||
|
## Recent Projects As The Reference
|
||||||
|
|
||||||
|
`src/features/recent-projects` is the first slice that follows this standard end-to-end.
|
||||||
|
|
||||||
|
Use it as the example for:
|
||||||
|
|
||||||
|
- contracts ownership
|
||||||
|
- core/application separation
|
||||||
|
- composition-root wiring
|
||||||
|
- renderer dumb UI + hook orchestration
|
||||||
|
- browser-friendly transport direction
|
||||||
|
- feature-level lint guard rails
|
||||||
|
|
||||||
|
## Agent Graph As The Thin-Slice Reference
|
||||||
|
|
||||||
|
`src/features/agent-graph` is the thin-slice example for a renderer integration
|
||||||
|
feature built on top of a reusable package.
|
||||||
|
|
||||||
|
Use it as the example for:
|
||||||
|
|
||||||
|
- keeping pure graph semantics in `core/domain`
|
||||||
|
- exposing a renderer-only public entrypoint
|
||||||
|
- integrating `packages/agent-graph` without inventing fake process layers
|
||||||
|
- migrating legacy `src/renderer/features/*` code into the canonical feature root
|
||||||
1260
docs/research/codex-dashboard-recent-projects-plan.md
Normal file
1260
docs/research/codex-dashboard-recent-projects-plan.md
Normal file
File diff suppressed because it is too large
Load diff
2443
docs/research/tmux-hybrid-installer-plan.md
Normal file
2443
docs/research/tmux-hybrid-installer-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -73,6 +73,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@preload': resolve(__dirname, 'src/preload')
|
'@preload': resolve(__dirname, 'src/preload')
|
||||||
|
|
@ -111,6 +112,7 @@ export default defineConfig({
|
||||||
preload: {
|
preload: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@preload': resolve(__dirname, 'src/preload'),
|
'@preload': resolve(__dirname, 'src/preload'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main')
|
'@main': resolve(__dirname, 'src/main')
|
||||||
|
|
@ -141,6 +143,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
'@features': resolve(__dirname, 'src/features'),
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
'@shared': resolve(__dirname, 'src/shared'),
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
|
|
|
||||||
384
eslint.config.js
384
eslint.config.js
|
|
@ -78,7 +78,7 @@ export default defineConfig([
|
||||||
// Import plugin configuration - Renderer (uses tsconfig.json)
|
// Import plugin configuration - Renderer (uses tsconfig.json)
|
||||||
{
|
{
|
||||||
name: 'import-plugin-renderer',
|
name: 'import-plugin-renderer',
|
||||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'],
|
||||||
plugins: {
|
plugins: {
|
||||||
import: importPlugin,
|
import: importPlugin,
|
||||||
},
|
},
|
||||||
|
|
@ -96,6 +96,255 @@ export default defineConfig([
|
||||||
'import/no-default-export': 'warn',
|
'import/no-default-export': 'warn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Feature-specific architecture guard rails - recent-projects
|
||||||
|
{
|
||||||
|
name: 'feature-recent-projects-public-entrypoints',
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
ignores: ['src/features/recent-projects/**/*'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/recent-projects/contracts/**',
|
||||||
|
'@features/recent-projects/core/**',
|
||||||
|
'@features/recent-projects/main/**',
|
||||||
|
'@features/recent-projects/preload/**',
|
||||||
|
'@features/recent-projects/renderer/**',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'Import recent-projects only through its public entrypoints: @features/recent-projects/contracts, @features/recent-projects/main, @features/recent-projects/preload, or @features/recent-projects/renderer.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-recent-projects-core-domain-guards',
|
||||||
|
files: ['src/features/recent-projects/core/domain/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/recent-projects/core/application/**',
|
||||||
|
'@features/recent-projects/main/**',
|
||||||
|
'@features/recent-projects/preload/**',
|
||||||
|
'@features/recent-projects/renderer/**',
|
||||||
|
'@main/**',
|
||||||
|
'@renderer/**',
|
||||||
|
'@preload/**',
|
||||||
|
'electron',
|
||||||
|
'fastify',
|
||||||
|
'child_process',
|
||||||
|
'node:child_process',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'recent-projects core/domain must stay side-effect free and cannot depend on application, adapters, infrastructure, or platform code.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-recent-projects-core-application-guards',
|
||||||
|
files: ['src/features/recent-projects/core/application/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/recent-projects/main/**',
|
||||||
|
'@features/recent-projects/preload/**',
|
||||||
|
'@features/recent-projects/renderer/**',
|
||||||
|
'@renderer/**',
|
||||||
|
'electron',
|
||||||
|
'fastify',
|
||||||
|
'child_process',
|
||||||
|
'node:child_process',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'recent-projects core/application may depend only on domain, contracts, and application ports - not on adapters or runtime frameworks.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-recent-projects-preload-guards',
|
||||||
|
files: ['src/features/recent-projects/preload/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/recent-projects/main/**',
|
||||||
|
'@main/**',
|
||||||
|
'@renderer/**',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'recent-projects preload may depend only on contracts and preload-local bridge helpers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-recent-projects-renderer-ui-guards',
|
||||||
|
files: ['src/features/recent-projects/renderer/ui/**/*.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@renderer/api',
|
||||||
|
'@renderer/api/**',
|
||||||
|
'@renderer/store',
|
||||||
|
'@renderer/store/**',
|
||||||
|
'@main/**',
|
||||||
|
'electron',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'recent-projects renderer/ui must stay presentational. Move transport, store access, and navigation logic into hooks or adapters.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-agent-graph-public-entrypoints',
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
ignores: ['src/features/agent-graph/**/*'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/agent-graph/core/**',
|
||||||
|
'@features/agent-graph/renderer/**',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'Import agent-graph only through its public entrypoint: @features/agent-graph/renderer.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-agent-graph-core-domain-guards',
|
||||||
|
files: ['src/features/agent-graph/core/domain/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/agent-graph/renderer/**',
|
||||||
|
'@main/**',
|
||||||
|
'@renderer/**',
|
||||||
|
'@preload/**',
|
||||||
|
'electron',
|
||||||
|
'fastify',
|
||||||
|
'child_process',
|
||||||
|
'node:child_process',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'agent-graph core/domain must stay pure and cannot depend on renderer, main, preload, or platform code.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feature-agent-graph-renderer-boundaries',
|
||||||
|
files: ['src/features/agent-graph/renderer/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@main/**',
|
||||||
|
'@preload/**',
|
||||||
|
'electron',
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'agent-graph renderer may depend on shared, renderer, package, and feature-local modules, but not on main/preload or Electron APIs directly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import plugin configuration - Feature main/preload slices
|
||||||
|
{
|
||||||
|
name: 'import-plugin-features-node',
|
||||||
|
files: ['src/features/**/main/**/*.ts', 'src/features/**/preload/**/*.ts'],
|
||||||
|
plugins: {
|
||||||
|
import: importPlugin,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.json'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
|
||||||
|
'import/no-unresolved': 'error',
|
||||||
|
'import/no-default-export': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import plugin configuration - Feature contracts/core/renderer slices
|
||||||
|
{
|
||||||
|
name: 'import-plugin-features-web',
|
||||||
|
files: [
|
||||||
|
'src/features/**/contracts/**/*.ts',
|
||||||
|
'src/features/**/core/**/*.ts',
|
||||||
|
'src/features/**/renderer/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
plugins: {
|
||||||
|
import: importPlugin,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
|
||||||
|
'import/no-unresolved': 'error',
|
||||||
|
'import/no-default-export': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Module boundaries - Enforce Electron three-process architecture
|
// Module boundaries - Enforce Electron three-process architecture
|
||||||
{
|
{
|
||||||
|
|
@ -548,7 +797,7 @@ export default defineConfig([
|
||||||
|
|
||||||
// === Import Restrictions ===
|
// === Import Restrictions ===
|
||||||
// Note: boundaries/element-types handles main/renderer separation
|
// Note: boundaries/element-types handles main/renderer separation
|
||||||
'no-restricted-imports': 'warn',
|
'no-restricted-imports': 'off',
|
||||||
|
|
||||||
// === Mutation Prevention ===
|
// === Mutation Prevention ===
|
||||||
'no-param-reassign': 'warn',
|
'no-param-reassign': 'warn',
|
||||||
|
|
@ -624,6 +873,137 @@ export default defineConfig([
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'feature-public-entrypoints-only',
|
||||||
|
files: [
|
||||||
|
'src/main/**/*.{ts,tsx}',
|
||||||
|
'src/preload/**/*.{ts,tsx}',
|
||||||
|
'src/renderer/**/*.{ts,tsx}',
|
||||||
|
'src/shared/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@features/*/contracts/*',
|
||||||
|
'@features/*/core/**',
|
||||||
|
'@features/*/main/*',
|
||||||
|
'@features/*/preload/*',
|
||||||
|
'@features/*/renderer/*',
|
||||||
|
],
|
||||||
|
message: 'Import feature public entrypoints only.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'feature-core-domain-guards',
|
||||||
|
files: ['src/features/*/core/domain/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{ name: 'electron', message: 'core/domain must stay Electron-free.' },
|
||||||
|
{ name: 'fastify', message: 'core/domain must stay transport-free.' },
|
||||||
|
{ name: 'child_process', message: 'core/domain must stay side-effect free.' },
|
||||||
|
{ name: 'node:child_process', message: 'core/domain must stay side-effect free.' },
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@main/*', '@preload/*', '@renderer/*'],
|
||||||
|
message: 'core/domain must stay process-agnostic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
|
||||||
|
message: 'core/domain must not import runtime or transport layers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'feature-core-application-guards',
|
||||||
|
files: ['src/features/*/core/application/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{ name: 'electron', message: 'core/application must stay Electron-free.' },
|
||||||
|
{ name: 'fastify', message: 'core/application must stay transport-free.' },
|
||||||
|
{ name: 'child_process', message: 'core/application must not spawn processes directly.' },
|
||||||
|
{
|
||||||
|
name: 'node:child_process',
|
||||||
|
message: 'core/application must not spawn processes directly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@main/*', '@preload/*', '@renderer/*'],
|
||||||
|
message: 'core/application must stay framework-agnostic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
|
||||||
|
message: 'core/application must depend on ports, not runtime adapters.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'feature-preload-guards',
|
||||||
|
files: ['src/features/*/preload/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['@main/*'],
|
||||||
|
message: 'Feature preload should not import app-shell main modules.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['@features/*/main/**'],
|
||||||
|
message: 'Feature preload must not reach into feature main internals.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'feature-renderer-ui-guards',
|
||||||
|
files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{ name: '@renderer/api', message: 'renderer/ui must stay presentational.' },
|
||||||
|
{ name: '@renderer/store', message: 'renderer/ui must stay store-free.' },
|
||||||
|
{ name: 'electron', message: 'renderer/ui must stay Electron-free.' },
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{ group: ['@main/*'], message: 'renderer/ui must not import main modules.' },
|
||||||
|
{ group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// === IMPORTANT: eslint-config-prettier MUST be LAST ===
|
// === IMPORTANT: eslint-config-prettier MUST be LAST ===
|
||||||
// This disables all ESLint rules that conflict with Prettier
|
// This disables all ESLint rules that conflict with Prettier
|
||||||
// Prettier handles formatting, ESLint handles code quality
|
// Prettier handles formatting, ESLint handles code quality
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"main": "dist-electron/main/index.cjs",
|
"main": "dist-electron/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||||
|
"dev:web": "node ./scripts/dev-web.mjs",
|
||||||
"dev:kill": "node bin/kill-dev.js",
|
"dev:kill": "node bin/kill-dev.js",
|
||||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
|
|
@ -321,6 +322,9 @@
|
||||||
"tsconfig*.json"
|
"tsconfig*.json"
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@features/*": [
|
||||||
|
"./src/features/*"
|
||||||
|
],
|
||||||
"@main/*": [
|
"@main/*": [
|
||||||
"./src/main/*"
|
"./src/main/*"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export function drawAgents(
|
||||||
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
||||||
|
|
||||||
// Breathing animation + launch-stage effects
|
// Breathing animation + launch-stage effects
|
||||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
|
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
||||||
drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
|
drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +122,17 @@ export function drawAgents(
|
||||||
if (!simplify) {
|
if (!simplify) {
|
||||||
// Name + role label (single line: "jack · developer")
|
// Name + role label (single line: "jack · developer")
|
||||||
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
||||||
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
|
drawLabel(
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r,
|
||||||
|
labelText,
|
||||||
|
color,
|
||||||
|
node.runtimeLabel,
|
||||||
|
node.launchStatusLabel,
|
||||||
|
node.launchVisualState
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
|
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
|
||||||
|
|
@ -256,52 +266,87 @@ function drawLaunchStage(
|
||||||
ctx.save();
|
ctx.save();
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
case 'waiting': {
|
case 'waiting': {
|
||||||
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
|
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
|
||||||
const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||||
|
const dotOrbit = r + 11;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
|
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.setLineDash([4, 5]);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'spawning': {
|
case 'spawning': {
|
||||||
const ringR = r + 7;
|
const ringR = r + 7;
|
||||||
const rotation = time * 2.4;
|
const rotation = time * 2.7;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
|
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
|
||||||
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72);
|
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.8;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4);
|
||||||
|
ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65);
|
||||||
|
ctx.lineWidth = 1.8;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12);
|
||||||
|
glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18));
|
||||||
|
glow.addColorStop(1, hexWithAlpha('#f59e0b', 0));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, ringR + 12, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fill();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'runtime_pending': {
|
case 'runtime_pending': {
|
||||||
const ringR = r + 8;
|
const ringR = r + 8;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4);
|
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.9;
|
||||||
|
ctx.setLineDash([5, 4]);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
const orbit = time * 1.6;
|
const orbit = time * 1.8;
|
||||||
const dotR = 2.2;
|
for (let index = 0; index < 2; index += 1) {
|
||||||
const dotX = x + Math.cos(orbit) * ringR;
|
const angle = orbit + Math.PI * index;
|
||||||
const dotY = y + Math.sin(orbit) * ringR;
|
const dotX = x + Math.cos(angle) * ringR;
|
||||||
ctx.beginPath();
|
const dotY = y + Math.sin(angle) * ringR;
|
||||||
ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
|
ctx.beginPath();
|
||||||
ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9);
|
ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'settling': {
|
case 'settling': {
|
||||||
const ringR = r + 6;
|
const ringR = r + 6;
|
||||||
const arc = 0.65 + 0.08 * Math.sin(time * 2.2);
|
const arc = 0.72 + 0.08 * Math.sin(time * 2.2);
|
||||||
const rotation = time * 1.25;
|
const rotation = time * 1.25;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18);
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
|
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
|
||||||
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55);
|
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62);
|
||||||
ctx.lineWidth = 1.75;
|
ctx.lineWidth = 2.2;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
break;
|
break;
|
||||||
|
|
@ -310,10 +355,15 @@ function drawLaunchStage(
|
||||||
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
|
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
|
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
|
||||||
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6);
|
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.4;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = hexWithAlpha('#f87171', 0.92);
|
||||||
|
ctx.fill();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -467,12 +517,8 @@ function drawBreathing(
|
||||||
r: number,
|
r: number,
|
||||||
state: string,
|
state: string,
|
||||||
time: number,
|
time: number,
|
||||||
spawnStatus?: GraphNode['spawnStatus'],
|
spawnStatus?: GraphNode['spawnStatus']
|
||||||
runtimeLabel?: string
|
|
||||||
): void {
|
): void {
|
||||||
const hasRuntimeLabel = Boolean(runtimeLabel?.trim());
|
|
||||||
const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14);
|
|
||||||
|
|
||||||
// Spawning: bright animated double ring + radial glow
|
// Spawning: bright animated double ring + radial glow
|
||||||
if (spawnStatus === 'spawning') {
|
if (spawnStatus === 'spawning') {
|
||||||
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
||||||
|
|
@ -505,12 +551,6 @@ function drawBreathing(
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// "connecting" label below name
|
|
||||||
ctx.font = '7px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2));
|
|
||||||
ctx.fillText('connecting...', x, serviceLabelY);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,12 +572,6 @@ function drawBreathing(
|
||||||
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
|
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// "waiting" label
|
|
||||||
ctx.font = '7px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5));
|
|
||||||
ctx.fillText('waiting...', x, serviceLabelY);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -649,7 +683,9 @@ function drawLabel(
|
||||||
r: number,
|
r: number,
|
||||||
label: string,
|
label: string,
|
||||||
color: string,
|
color: string,
|
||||||
runtimeLabel?: string
|
runtimeLabel?: string,
|
||||||
|
launchStatusLabel?: string,
|
||||||
|
launchVisualState?: GraphNode['launchVisualState']
|
||||||
): void {
|
): void {
|
||||||
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
||||||
ctx.font = '9px monospace';
|
ctx.font = '9px monospace';
|
||||||
|
|
@ -659,16 +695,27 @@ function drawLabel(
|
||||||
ctx.fillText(label, x, labelY);
|
ctx.fillText(label, x, labelY);
|
||||||
|
|
||||||
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
||||||
if (!trimmedRuntimeLabel) {
|
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
|
||||||
|
if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.font = '8px monospace';
|
let nextLineY = labelY + 10;
|
||||||
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
if (trimmedRuntimeLabel) {
|
||||||
ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10);
|
ctx.font = '8px monospace';
|
||||||
|
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
||||||
|
ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY);
|
||||||
|
nextLineY += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLaunchStatusLabel) {
|
||||||
|
ctx.font = '7px monospace';
|
||||||
|
ctx.fillStyle = getLaunchStatusColor(launchVisualState);
|
||||||
|
ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
||||||
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
||||||
if (ctx.measureText(label).width <= maxWidth) return label;
|
if (ctx.measureText(label).width <= maxWidth) return label;
|
||||||
|
|
||||||
|
|
@ -679,6 +726,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n
|
||||||
return `${out}…`;
|
return `${out}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
|
||||||
|
switch (visualState) {
|
||||||
|
case 'waiting':
|
||||||
|
return hexWithAlpha('#d4d4d8', 0.8);
|
||||||
|
case 'spawning':
|
||||||
|
return hexWithAlpha('#f59e0b', 0.9);
|
||||||
|
case 'runtime_pending':
|
||||||
|
return hexWithAlpha('#67e8f9', 0.9);
|
||||||
|
case 'settling':
|
||||||
|
return hexWithAlpha('#22c55e', 0.9);
|
||||||
|
case 'error':
|
||||||
|
return hexWithAlpha('#ef4444', 0.92);
|
||||||
|
default:
|
||||||
|
return hexWithAlpha(COLORS.holoBright, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw context usage ring around lead node.
|
* Draw context usage ring around lead node.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { STABLE_SLOT_GEOMETRY } from '../layout/stableSlotGeometry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canvas rendering constants for the agent graph visualization.
|
* Canvas rendering constants for the agent graph visualization.
|
||||||
* Adapted from agent-flow's canvas-constants.ts (Apache 2.0).
|
* Adapted from agent-flow's canvas-constants.ts (Apache 2.0).
|
||||||
|
|
@ -260,10 +262,12 @@ export const KANBAN_ZONE = {
|
||||||
columnWidth: 180,
|
columnWidth: 180,
|
||||||
/** Row height: pill (36) + gap (10) */
|
/** Row height: pill (36) + gap (10) */
|
||||||
rowHeight: 46,
|
rowHeight: 46,
|
||||||
|
/** Space reserved for column header label */
|
||||||
|
headerHeight: 20,
|
||||||
/** Zone starts this far below member node center */
|
/** Zone starts this far below member node center */
|
||||||
offsetY: 70,
|
offsetY: 70,
|
||||||
/** Column order: todo → wip → done → review → approved */
|
/** Column sequence: pending → wip → done → review → approved */
|
||||||
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
|
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
|
||||||
/** Max tasks shown per column (overflow hidden) */
|
/** Max tasks shown per column (overflow hidden) */
|
||||||
maxVisibleRows: 6,
|
maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ export function useGraphInteraction(
|
||||||
clickedNodeId.current = hit;
|
clickedNodeId.current = hit;
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
// Only allow drag on member/lead nodes, not tasks or processes
|
// Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots.
|
||||||
const hitNode = nodes.find((n) => n.id === hit);
|
const hitNode = nodes.find((n) => n.id === hit);
|
||||||
if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) {
|
if (hitNode?.kind === 'member') {
|
||||||
dragNodeId.current = hit;
|
dragNodeId.current = hit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
||||||
// ─── Components ──────────────────────────────────────────────────────────────
|
// ─── Components ──────────────────────────────────────────────────────────────
|
||||||
export { GraphView } from './ui/GraphView';
|
export { GraphView } from './ui/GraphView';
|
||||||
export type { GraphViewProps } from './ui/GraphView';
|
export type { GraphViewProps } from './ui/GraphView';
|
||||||
|
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
|
||||||
|
|
||||||
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
||||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||||
|
|
@ -21,6 +22,9 @@ export type {
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
GraphParticle,
|
GraphParticle,
|
||||||
GraphActivityItem,
|
GraphActivityItem,
|
||||||
|
GraphOwnerSlotAssignment,
|
||||||
|
GraphLayoutPort,
|
||||||
|
GraphLayoutVersion,
|
||||||
GraphNodeKind,
|
GraphNodeKind,
|
||||||
GraphNodeState,
|
GraphNodeState,
|
||||||
GraphLaunchVisualState,
|
GraphLaunchVisualState,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,20 @@
|
||||||
import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants';
|
import { CAMERA, NODE } from '../constants/canvas-constants';
|
||||||
import type { GraphActivityItem, GraphNode } from '../ports/types';
|
import type { GraphActivityItem, GraphNode } from '../ports/types';
|
||||||
|
import { createStableSlotActivityLane } from './stableSlotGeometry';
|
||||||
|
|
||||||
export const ACTIVITY_LANE = {
|
const STABLE_SLOT_ACTIVITY = createStableSlotActivityLane({
|
||||||
width: 296,
|
nodeMetrics: {
|
||||||
itemHeight: 58,
|
radiusLead: NODE.radiusLead,
|
||||||
rowHeight: 62,
|
radiusMember: NODE.radiusMember,
|
||||||
maxVisibleItems: 3,
|
},
|
||||||
headerHeight: 18,
|
zoomRange: {
|
||||||
overflowHeight: 18,
|
minZoom: CAMERA.minZoom,
|
||||||
horizontalGapLead: 76,
|
maxZoom: CAMERA.maxZoom,
|
||||||
horizontalGapMember: 84,
|
},
|
||||||
bottomClearance: 18,
|
});
|
||||||
viewportPadding: 12,
|
|
||||||
visiblePadding: 80,
|
|
||||||
minScale: 0,
|
|
||||||
maxScale: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const RESERVED_HEIGHT =
|
export const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane;
|
||||||
ACTIVITY_LANE.headerHeight
|
export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor;
|
||||||
+ ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight
|
|
||||||
+ ACTIVITY_LANE.overflowHeight;
|
|
||||||
|
|
||||||
export const ACTIVITY_ANCHOR_LAYOUT = {
|
|
||||||
reservedWidth: ACTIVITY_LANE.width,
|
|
||||||
reservedHeight: RESERVED_HEIGHT,
|
|
||||||
memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember,
|
|
||||||
memberOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
|
|
||||||
leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead),
|
|
||||||
leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance),
|
|
||||||
collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface ActivityLaneWindow {
|
export interface ActivityLaneWindow {
|
||||||
items: GraphActivityItem[];
|
items: GraphActivityItem[];
|
||||||
|
|
@ -51,6 +35,32 @@ export interface ActivityLaneItemHit {
|
||||||
|
|
||||||
export type ActivityLaneSide = 'left' | 'right';
|
export type ActivityLaneSide = 'left' | 'right';
|
||||||
|
|
||||||
|
export interface ActivityLaneScreenRect {
|
||||||
|
id: string;
|
||||||
|
side: ActivityLaneSide;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLaneWorldRect {
|
||||||
|
id: string;
|
||||||
|
side: ActivityLaneSide;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLaneWorldBounds {
|
||||||
|
ownerId: string;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveActivityLaneSide(args: {
|
export function resolveActivityLaneSide(args: {
|
||||||
nodeKind: 'lead' | 'member';
|
nodeKind: 'lead' | 'member';
|
||||||
nodeX: number;
|
nodeX: number;
|
||||||
|
|
@ -72,18 +82,14 @@ export function getActivityAnchorTarget(args: {
|
||||||
nodeKind: 'lead' | 'member';
|
nodeKind: 'lead' | 'member';
|
||||||
leadX?: number | null;
|
leadX?: number | null;
|
||||||
}): { x: number; y: number } {
|
}): { x: number; y: number } {
|
||||||
const { nodeX, nodeY, nodeKind, leadX } = args;
|
const { nodeX, nodeY, nodeKind } = args;
|
||||||
const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX });
|
|
||||||
if (side === 'left') {
|
|
||||||
return {
|
|
||||||
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX,
|
|
||||||
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX,
|
x: nodeX - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2,
|
||||||
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY,
|
y:
|
||||||
|
nodeY +
|
||||||
|
(nodeKind === 'lead'
|
||||||
|
? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY
|
||||||
|
: ACTIVITY_ANCHOR_LAYOUT.memberOffsetY),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,16 +99,42 @@ export function getActivityLaneBounds(anchorX: number, anchorY: number): {
|
||||||
right: number;
|
right: number;
|
||||||
bottom: number;
|
bottom: number;
|
||||||
} {
|
} {
|
||||||
const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2;
|
|
||||||
const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2;
|
|
||||||
return {
|
return {
|
||||||
left: anchorX - halfWidth,
|
left: anchorX,
|
||||||
top: anchorY - halfHeight,
|
top: anchorY,
|
||||||
right: anchorX + halfWidth,
|
right: anchorX + ACTIVITY_ANCHOR_LAYOUT.reservedWidth,
|
||||||
bottom: anchorY + halfHeight,
|
bottom: anchorY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildVisibleActivityLaneBounds(
|
||||||
|
nodes: GraphNode[],
|
||||||
|
activityPositions: ReadonlyMap<string, { x: number; y: number }>
|
||||||
|
): ActivityLaneWorldBounds[] {
|
||||||
|
const bounds: ActivityLaneWorldBounds[] = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind !== 'lead' && node.kind !== 'member') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const visibleCount = node.activityItems?.length ?? 0;
|
||||||
|
const overflowCount = node.activityOverflowCount ?? 0;
|
||||||
|
if (visibleCount <= 0 && overflowCount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const topLeft = activityPositions.get(node.id);
|
||||||
|
if (!topLeft) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bounds.push({
|
||||||
|
ownerId: node.id,
|
||||||
|
...getActivityLaneBounds(topLeft.x, topLeft.y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
export function getActivityLaneScale(zoom: number): number {
|
export function getActivityLaneScale(zoom: number): number {
|
||||||
return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom));
|
return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom));
|
||||||
}
|
}
|
||||||
|
|
@ -120,10 +152,8 @@ export function getActivityAnchorScreenPlacement(args: {
|
||||||
const scale = getActivityLaneScale(zoom);
|
const scale = getActivityLaneScale(zoom);
|
||||||
const scaledWidth = ACTIVITY_LANE.width * scale;
|
const scaledWidth = ACTIVITY_LANE.width * scale;
|
||||||
const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
|
const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
|
||||||
const screenX = anchorX * zoom + cameraX;
|
const x = anchorX * zoom + cameraX;
|
||||||
const screenY = anchorY * zoom + cameraY;
|
const y = anchorY * zoom + cameraY;
|
||||||
const x = screenX - scaledWidth / 2;
|
|
||||||
const y = screenY - scaledHeight / 2;
|
|
||||||
const right = x + scaledWidth;
|
const right = x + scaledWidth;
|
||||||
const bottom = y + scaledHeight;
|
const bottom = y + scaledHeight;
|
||||||
|
|
||||||
|
|
@ -152,6 +182,49 @@ export function getVisibleActivityWindow(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function packActivityLaneScreenRects(
|
||||||
|
rects: ActivityLaneScreenRect[],
|
||||||
|
gap = 8
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
return packActivityLaneRects(rects, gap, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packActivityLaneWorldRects(
|
||||||
|
rects: ActivityLaneWorldRect[],
|
||||||
|
gap = 8
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
return packActivityLaneRects(rects, gap, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function packActivityLaneRects<T extends {
|
||||||
|
id: string;
|
||||||
|
side: ActivityLaneSide;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>(
|
||||||
|
rects: T[],
|
||||||
|
gap = 8,
|
||||||
|
groupBySide = true
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const placements = new Map<string, { x: number; y: number }>();
|
||||||
|
for (const side of resolvePackedActivitySides(groupBySide)) {
|
||||||
|
const sideRects = rects
|
||||||
|
.filter((rect) => !groupBySide || rect.side === side)
|
||||||
|
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
|
||||||
|
const placed: (T & { placedY: number })[] = [];
|
||||||
|
|
||||||
|
for (const rect of sideRects) {
|
||||||
|
const placedY = resolvePackedActivityY(rect, placed, gap);
|
||||||
|
placed.push({ ...rect, placedY });
|
||||||
|
placements.set(rect.id, { x: rect.x, y: placedY });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return placements;
|
||||||
|
}
|
||||||
|
|
||||||
export function findActivityItemAt(
|
export function findActivityItemAt(
|
||||||
worldX: number,
|
worldX: number,
|
||||||
worldY: number,
|
worldY: number,
|
||||||
|
|
@ -177,13 +250,17 @@ export function findActivityItemAt(
|
||||||
|
|
||||||
for (let index = 0; index < items.length; index += 1) {
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
|
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
|
||||||
|
const item = items.at(index);
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
worldX >= left &&
|
worldX >= left &&
|
||||||
worldX <= left + ACTIVITY_LANE.width &&
|
worldX <= left + ACTIVITY_LANE.width &&
|
||||||
worldY >= itemTop &&
|
worldY >= itemTop &&
|
||||||
worldY <= itemTop + ACTIVITY_LANE.itemHeight
|
worldY <= itemTop + ACTIVITY_LANE.itemHeight
|
||||||
) {
|
) {
|
||||||
return { ownerNodeId: node.id, item: items[index] };
|
return { ownerNodeId: node.id, item };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,3 +271,37 @@ export function findActivityItemAt(
|
||||||
export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } {
|
export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } {
|
||||||
return node.kind === 'lead' || node.kind === 'member';
|
return node.kind === 'lead' || node.kind === 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||||
|
return aStart < bEnd && aEnd > bStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackedActivitySides(groupBySide: boolean): readonly ActivityLaneSide[] {
|
||||||
|
return groupBySide ? ['left', 'right'] : ['left'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackedActivityY<T extends {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>(
|
||||||
|
rect: T,
|
||||||
|
placed: readonly (T & { placedY: number })[],
|
||||||
|
gap: number
|
||||||
|
): number {
|
||||||
|
let placedY = rect.y;
|
||||||
|
|
||||||
|
for (const prev of placed) {
|
||||||
|
if (!rangesOverlap(rect.x, rect.x + rect.width, prev.x, prev.x + prev.width)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevBottom = prev.placedY + prev.height;
|
||||||
|
if (placedY < prevBottom + gap && placedY + rect.height > prev.placedY - gap) {
|
||||||
|
placedY = prevBottom + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return placedY;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { KANBAN_ZONE } from '../constants/canvas-constants';
|
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import { COLORS } from '../constants/colors';
|
import { COLORS } from '../constants/colors';
|
||||||
import { resolveActivityLaneSide } from './activityLane';
|
import type { SlotFrame, StableRect } from './stableSlots';
|
||||||
|
|
||||||
/** Column header info for rendering */
|
/** Column header info for rendering */
|
||||||
export interface KanbanColumnHeader {
|
export interface KanbanColumnHeader {
|
||||||
|
|
@ -48,7 +48,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
leadX?: number | null;
|
leadX?: number | null;
|
||||||
}): number {
|
}): number {
|
||||||
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
|
const { ownerX, ownerKind, activeColumnCount, columnWidth } = args;
|
||||||
if (activeColumnCount <= 0) {
|
if (activeColumnCount <= 0) {
|
||||||
return ownerX;
|
return ownerX;
|
||||||
}
|
}
|
||||||
|
|
@ -57,17 +57,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
return ownerX - (activeColumnCount * columnWidth) / 2;
|
return ownerX - (activeColumnCount * columnWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const side = resolveActivityLaneSide({
|
return ownerX - ((activeColumnCount - 1) * columnWidth) / 2;
|
||||||
nodeKind: ownerKind,
|
|
||||||
nodeX: ownerX,
|
|
||||||
leadX,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (side === 'left') {
|
|
||||||
return ownerX;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ownerX - (activeColumnCount - 1) * columnWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KanbanLayoutEngine {
|
export class KanbanLayoutEngine {
|
||||||
|
|
@ -78,26 +68,52 @@ export class KanbanLayoutEngine {
|
||||||
static readonly #colTasks = new Map<string, GraphNode[]>();
|
static readonly #colTasks = new Map<string, GraphNode[]>();
|
||||||
|
|
||||||
/** Zone info for rendering column headers — updated each layout() call */
|
/** Zone info for rendering column headers — updated each layout() call */
|
||||||
static zones: KanbanZoneInfo[] = [];
|
static readonly zones: KanbanZoneInfo[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position all task nodes in kanban columns relative to their owner.
|
* Position all task nodes in kanban columns relative to their owner.
|
||||||
* Call AFTER d3-force settles member positions, BEFORE drawing.
|
* Call AFTER d3-force settles member positions, BEFORE drawing.
|
||||||
*/
|
*/
|
||||||
static layout(nodes: GraphNode[]): void {
|
static layout(
|
||||||
|
nodes: GraphNode[],
|
||||||
|
options?: {
|
||||||
|
memberSlotFrames?: readonly SlotFrame[];
|
||||||
|
leadSlotFrame?: SlotFrame | null;
|
||||||
|
unassignedTaskRect?: StableRect | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
const nodeMap = this.#nodeMap;
|
const nodeMap = this.#nodeMap;
|
||||||
nodeMap.clear();
|
nodeMap.clear();
|
||||||
for (const n of nodes) nodeMap.set(n.id, n);
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
||||||
|
const ownerSlotFrameByOwnerId = new Map(
|
||||||
|
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
|
||||||
|
);
|
||||||
|
if (options?.leadSlotFrame) {
|
||||||
|
ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame);
|
||||||
|
}
|
||||||
|
|
||||||
const tasksByOwner = this.#tasksByOwner;
|
const tasksByOwner = this.#tasksByOwner;
|
||||||
tasksByOwner.clear();
|
tasksByOwner.clear();
|
||||||
const unassigned = this.#unassigned;
|
const unassigned = this.#unassigned;
|
||||||
unassigned.length = 0;
|
unassigned.length = 0;
|
||||||
|
const hasLayoutOwner = (ownerId: string): boolean => {
|
||||||
|
const owner = nodeMap.get(ownerId);
|
||||||
|
if (!owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (owner.kind === 'lead') {
|
||||||
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
|
}
|
||||||
|
if (owner.kind === 'member') {
|
||||||
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.kind !== 'task') continue;
|
if (n.kind !== 'task') continue;
|
||||||
if (n.ownerId) {
|
if (n.ownerId && hasLayoutOwner(n.ownerId)) {
|
||||||
let group = tasksByOwner.get(n.ownerId);
|
let group = tasksByOwner.get(n.ownerId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = [];
|
group = [];
|
||||||
|
|
@ -110,16 +126,22 @@ export class KanbanLayoutEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset zones
|
// Reset zones
|
||||||
this.zones = [];
|
this.zones.length = 0;
|
||||||
|
|
||||||
for (const [ownerId, tasks] of tasksByOwner) {
|
for (const [ownerId, tasks] of tasksByOwner) {
|
||||||
const owner = nodeMap.get(ownerId);
|
const owner = nodeMap.get(ownerId);
|
||||||
if (!owner || owner.x == null || owner.y == null) continue;
|
if (owner?.x == null || owner?.y == null) continue;
|
||||||
const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX);
|
const zoneInfo = KanbanLayoutEngine.#layoutZone(
|
||||||
|
tasks,
|
||||||
|
owner,
|
||||||
|
ownerId,
|
||||||
|
leadX,
|
||||||
|
ownerSlotFrameByOwnerId.get(ownerId) ?? null
|
||||||
|
);
|
||||||
if (zoneInfo) this.zones.push(zoneInfo);
|
if (zoneInfo) this.zones.push(zoneInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes);
|
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private ──────────────────────────────────────────────────────────────
|
// ─── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -128,13 +150,12 @@ export class KanbanLayoutEngine {
|
||||||
tasks: GraphNode[],
|
tasks: GraphNode[],
|
||||||
owner: GraphNode,
|
owner: GraphNode,
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
leadX: number | null
|
leadX: number | null,
|
||||||
|
slotFrame: SlotFrame | null
|
||||||
): KanbanZoneInfo | null {
|
): KanbanZoneInfo | null {
|
||||||
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
|
const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE;
|
||||||
const headerHeight = 20; // space for column header label
|
|
||||||
const ownerX = owner.x ?? 0;
|
const ownerX = owner.x ?? 0;
|
||||||
const ownerY = owner.y ?? 0;
|
const ownerY = owner.y ?? 0;
|
||||||
const baseY = ownerY + offsetY;
|
|
||||||
|
|
||||||
// Classify tasks into columns
|
// Classify tasks into columns
|
||||||
const colTasks = KanbanLayoutEngine.#colTasks;
|
const colTasks = KanbanLayoutEngine.#colTasks;
|
||||||
|
|
@ -157,15 +178,21 @@ export class KanbanLayoutEngine {
|
||||||
|
|
||||||
if (activeColumns.length === 0) return null;
|
if (activeColumns.length === 0) return null;
|
||||||
|
|
||||||
// Keep kanban columns on the open side of the owner, away from the reserved activity lane.
|
let baseX = getOwnerKanbanBaseX({
|
||||||
// This makes member lanes reserve real visual space instead of only affecting the force layout.
|
|
||||||
const baseX = getOwnerKanbanBaseX({
|
|
||||||
ownerX,
|
ownerX,
|
||||||
ownerKind: owner.kind,
|
ownerKind: owner.kind,
|
||||||
activeColumnCount: activeColumns.length,
|
activeColumnCount: activeColumns.length,
|
||||||
columnWidth,
|
columnWidth,
|
||||||
leadX,
|
leadX,
|
||||||
});
|
});
|
||||||
|
let baseY: number;
|
||||||
|
|
||||||
|
if (slotFrame) {
|
||||||
|
baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2;
|
||||||
|
baseY = slotFrame.kanbanBandRect.top;
|
||||||
|
} else {
|
||||||
|
baseY = ownerY + offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
// Build headers + position tasks
|
// Build headers + position tasks
|
||||||
const headers: KanbanColumnHeader[] = [];
|
const headers: KanbanColumnHeader[] = [];
|
||||||
|
|
@ -190,8 +217,8 @@ export class KanbanLayoutEngine {
|
||||||
for (const [rowIdx, task] of col.tasks.entries()) {
|
for (const [rowIdx, task] of col.tasks.entries()) {
|
||||||
const targetX = colX;
|
const targetX = colX;
|
||||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||||
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
task.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
||||||
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||||
task.fx = task.x;
|
task.fx = task.x;
|
||||||
task.fy = task.y;
|
task.fy = task.y;
|
||||||
task.vx = 0;
|
task.vx = 0;
|
||||||
|
|
@ -215,11 +242,52 @@ export class KanbanLayoutEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static #layoutUnassigned(tasks: GraphNode[], allNodes: GraphNode[]): void {
|
static #layoutUnassigned(
|
||||||
|
tasks: GraphNode[],
|
||||||
|
allNodes: GraphNode[],
|
||||||
|
unassignedTaskRect: StableRect | null
|
||||||
|
): void {
|
||||||
if (tasks.length === 0) return;
|
if (tasks.length === 0) return;
|
||||||
|
|
||||||
const { columnWidth, rowHeight } = KANBAN_ZONE;
|
const { columnWidth, rowHeight } = KANBAN_ZONE;
|
||||||
|
|
||||||
|
if (unassignedTaskRect) {
|
||||||
|
const cols = Math.min(Math.max(tasks.length, 1), 5);
|
||||||
|
const baseX = unassignedTaskRect.left + TASK_PILL.width / 2;
|
||||||
|
const baseY = unassignedTaskRect.top;
|
||||||
|
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
|
||||||
|
|
||||||
|
this.zones.push({
|
||||||
|
ownerId: '__unassigned__',
|
||||||
|
ownerX: 0,
|
||||||
|
ownerY: baseY - 48,
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
label: 'Unassigned',
|
||||||
|
x: 0,
|
||||||
|
y: baseY,
|
||||||
|
color: COLORS.taskPending,
|
||||||
|
overflowCount,
|
||||||
|
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [idx, task] of tasks.entries()) {
|
||||||
|
const col = idx % cols;
|
||||||
|
const row = Math.floor(idx / cols);
|
||||||
|
const targetX = baseX + col * columnWidth;
|
||||||
|
const targetY = baseY + row * rowHeight;
|
||||||
|
task.x = targetX;
|
||||||
|
task.y = targetY;
|
||||||
|
task.fx = targetX;
|
||||||
|
task.fy = targetY;
|
||||||
|
task.vx = 0;
|
||||||
|
task.vy = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Find the lowest Y of ALL positioned nodes (members + their owned tasks)
|
// Find the lowest Y of ALL positioned nodes (members + their owned tasks)
|
||||||
let sumX = 0;
|
let sumX = 0;
|
||||||
let maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NODE } from '../constants/canvas-constants';
|
import { NODE } from '../constants/canvas-constants';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_ANCHOR_LAYOUT,
|
ACTIVITY_ANCHOR_LAYOUT,
|
||||||
getActivityAnchorTarget,
|
resolveActivityLaneSide,
|
||||||
getActivityLaneBounds,
|
|
||||||
} from './activityLane';
|
} from './activityLane';
|
||||||
|
import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry';
|
||||||
|
|
||||||
export interface WorldBounds {
|
export interface WorldBounds {
|
||||||
left: number;
|
left: number;
|
||||||
|
|
@ -19,17 +19,9 @@ export interface LaunchAnchorScreenPlacement {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LAUNCH_ANCHOR_LAYOUT = {
|
export const LAUNCH_ANCHOR_LAYOUT = createStableSlotLaunchAnchorLayout({
|
||||||
compactWidth: 336,
|
radiusLead: NODE.radiusLead,
|
||||||
compactHeight: 132,
|
});
|
||||||
anchorCenterOffsetX: 336 / 2 + NODE.radiusLead + 40,
|
|
||||||
anchorCenterOffsetY: -(132 / 2 + NODE.radiusLead + 36),
|
|
||||||
collisionRadius: Math.ceil(Math.hypot(336 / 2, 132 / 2)) + 14,
|
|
||||||
viewportPadding: 12,
|
|
||||||
visiblePadding: 80,
|
|
||||||
minScale: 0,
|
|
||||||
maxScale: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:';
|
const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:';
|
||||||
const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:';
|
const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:';
|
||||||
|
|
@ -76,9 +68,38 @@ export const getLaunchHudBounds = getLaunchAnchorBounds;
|
||||||
export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT;
|
export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT;
|
||||||
export const getHandoffAnchorId = getActivityAnchorId;
|
export const getHandoffAnchorId = getActivityAnchorId;
|
||||||
export const isHandoffAnchorId = isActivityAnchorId;
|
export const isHandoffAnchorId = isActivityAnchorId;
|
||||||
export { getActivityAnchorTarget };
|
|
||||||
export const getHandoffAnchorTarget = getActivityAnchorTarget;
|
export function getHandoffAnchorTarget(args: {
|
||||||
export const getHandoffAnchorBounds = getActivityLaneBounds;
|
nodeX: number;
|
||||||
|
nodeY: number;
|
||||||
|
nodeKind: 'lead' | 'member';
|
||||||
|
leadX?: number | null;
|
||||||
|
}): { x: number; y: number } {
|
||||||
|
const { nodeX, nodeY, nodeKind, leadX } = args;
|
||||||
|
const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX });
|
||||||
|
if (side === 'left') {
|
||||||
|
return {
|
||||||
|
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX,
|
||||||
|
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX,
|
||||||
|
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHandoffAnchorBounds(anchorX: number, anchorY: number): WorldBounds {
|
||||||
|
const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2;
|
||||||
|
const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2;
|
||||||
|
return {
|
||||||
|
left: anchorX - halfWidth,
|
||||||
|
top: anchorY - halfHeight,
|
||||||
|
right: anchorX + halfWidth,
|
||||||
|
bottom: anchorY + halfHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getLaunchAnchorScreenPlacement(args: {
|
export function getLaunchAnchorScreenPlacement(args: {
|
||||||
anchorX: number;
|
anchorX: number;
|
||||||
|
|
|
||||||
158
packages/agent-graph/src/layout/stableSlotGeometry.ts
Normal file
158
packages/agent-graph/src/layout/stableSlotGeometry.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
export const STABLE_SLOT_GEOMETRY = {
|
||||||
|
slotVerticalGap: 24,
|
||||||
|
slotHorizontalGap: 32,
|
||||||
|
ringGap: 140,
|
||||||
|
centralSafetyPadding: 48,
|
||||||
|
memberSlotInnerPadding: 16,
|
||||||
|
centralBlockGap: 56,
|
||||||
|
ringPadding: 32,
|
||||||
|
unassignedGap: 72,
|
||||||
|
maxGeneratedRings: 12,
|
||||||
|
ownerCollisionPadding: 28,
|
||||||
|
ownerBandHeight: 96,
|
||||||
|
ownerMinWidth: 200,
|
||||||
|
processBandHeight: 32,
|
||||||
|
processRailWidth: 220,
|
||||||
|
taskMaxVisibleRows: 5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const STABLE_SLOT_SECTOR_VECTORS = [
|
||||||
|
{ x: 0, y: -1 },
|
||||||
|
{ x: 0.82, y: -0.57 },
|
||||||
|
{ x: 0.82, y: 0.57 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: -0.82, y: 0.57 },
|
||||||
|
{ x: -0.82, y: -0.57 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface StableSlotNodeMetrics {
|
||||||
|
radiusLead: number;
|
||||||
|
radiusMember: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StableSlotZoomRange {
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StableSlotActivityLane {
|
||||||
|
width: number;
|
||||||
|
itemHeight: number;
|
||||||
|
rowHeight: number;
|
||||||
|
maxVisibleItems: number;
|
||||||
|
headerHeight: number;
|
||||||
|
overflowHeight: number;
|
||||||
|
horizontalGapLead: number;
|
||||||
|
horizontalGapMember: number;
|
||||||
|
ownerClearanceLead: number;
|
||||||
|
ownerClearanceMember: number;
|
||||||
|
viewportPadding: number;
|
||||||
|
visiblePadding: number;
|
||||||
|
minScale: number;
|
||||||
|
maxScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StableSlotActivityAnchorLayout {
|
||||||
|
reservedWidth: number;
|
||||||
|
reservedHeight: number;
|
||||||
|
memberOffsetX: number;
|
||||||
|
memberOffsetY: number;
|
||||||
|
leadOffsetX: number;
|
||||||
|
leadOffsetY: number;
|
||||||
|
collisionRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StableSlotLaunchAnchorLayout {
|
||||||
|
compactWidth: number;
|
||||||
|
compactHeight: number;
|
||||||
|
anchorCenterOffsetX: number;
|
||||||
|
anchorCenterOffsetY: number;
|
||||||
|
collisionRadius: number;
|
||||||
|
viewportPadding: number;
|
||||||
|
visiblePadding: number;
|
||||||
|
minScale: number;
|
||||||
|
maxScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_LANE_BASE = {
|
||||||
|
width: 296,
|
||||||
|
itemHeight: 72,
|
||||||
|
rowHeight: 80,
|
||||||
|
maxVisibleItems: 3,
|
||||||
|
headerHeight: 20,
|
||||||
|
overflowHeight: 32,
|
||||||
|
horizontalGapLead: 76,
|
||||||
|
horizontalGapMember: 84,
|
||||||
|
ownerClearanceLead: 92,
|
||||||
|
ownerClearanceMember: 104,
|
||||||
|
viewportPadding: 12,
|
||||||
|
visiblePadding: 80,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LAUNCH_HUD_BASE = {
|
||||||
|
compactWidth: 336,
|
||||||
|
compactHeight: 132,
|
||||||
|
horizontalGap: 40,
|
||||||
|
verticalClearance: 36,
|
||||||
|
viewportPadding: 12,
|
||||||
|
visiblePadding: 80,
|
||||||
|
minScale: 0,
|
||||||
|
maxScale: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function createStableSlotActivityLane(args: {
|
||||||
|
nodeMetrics: StableSlotNodeMetrics;
|
||||||
|
zoomRange: StableSlotZoomRange;
|
||||||
|
}): {
|
||||||
|
lane: StableSlotActivityLane;
|
||||||
|
anchor: StableSlotActivityAnchorLayout;
|
||||||
|
} {
|
||||||
|
const { nodeMetrics, zoomRange } = args;
|
||||||
|
const lane: StableSlotActivityLane = {
|
||||||
|
...ACTIVITY_LANE_BASE,
|
||||||
|
minScale: zoomRange.minZoom,
|
||||||
|
maxScale: zoomRange.maxZoom,
|
||||||
|
};
|
||||||
|
const reservedHeight =
|
||||||
|
lane.headerHeight +
|
||||||
|
lane.maxVisibleItems * lane.rowHeight +
|
||||||
|
lane.overflowHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lane,
|
||||||
|
anchor: {
|
||||||
|
reservedWidth: lane.width,
|
||||||
|
reservedHeight,
|
||||||
|
memberOffsetX: lane.width / 2 + nodeMetrics.radiusMember + lane.horizontalGapMember,
|
||||||
|
memberOffsetY: -(reservedHeight + nodeMetrics.radiusMember + lane.ownerClearanceMember),
|
||||||
|
leadOffsetX: -(lane.width / 2 + nodeMetrics.radiusLead + lane.horizontalGapLead),
|
||||||
|
leadOffsetY: -(reservedHeight + nodeMetrics.radiusLead + lane.ownerClearanceLead),
|
||||||
|
collisionRadius: Math.ceil(Math.hypot(lane.width / 2, reservedHeight / 2)) + 56,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStableSlotLaunchAnchorLayout(
|
||||||
|
nodeMetrics: Pick<StableSlotNodeMetrics, 'radiusLead'>
|
||||||
|
): StableSlotLaunchAnchorLayout {
|
||||||
|
const { radiusLead } = nodeMetrics;
|
||||||
|
return {
|
||||||
|
compactWidth: LAUNCH_HUD_BASE.compactWidth,
|
||||||
|
compactHeight: LAUNCH_HUD_BASE.compactHeight,
|
||||||
|
anchorCenterOffsetX:
|
||||||
|
LAUNCH_HUD_BASE.compactWidth / 2 + radiusLead + LAUNCH_HUD_BASE.horizontalGap,
|
||||||
|
anchorCenterOffsetY:
|
||||||
|
-(LAUNCH_HUD_BASE.compactHeight / 2 + radiusLead + LAUNCH_HUD_BASE.verticalClearance),
|
||||||
|
collisionRadius:
|
||||||
|
Math.ceil(
|
||||||
|
Math.hypot(
|
||||||
|
LAUNCH_HUD_BASE.compactWidth / 2,
|
||||||
|
LAUNCH_HUD_BASE.compactHeight / 2
|
||||||
|
)
|
||||||
|
) + 14,
|
||||||
|
viewportPadding: LAUNCH_HUD_BASE.viewportPadding,
|
||||||
|
visiblePadding: LAUNCH_HUD_BASE.visiblePadding,
|
||||||
|
minScale: LAUNCH_HUD_BASE.minScale,
|
||||||
|
maxScale: LAUNCH_HUD_BASE.maxScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
1814
packages/agent-graph/src/layout/stableSlots.ts
Normal file
1814
packages/agent-graph/src/layout/stableSlots.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
import type { GraphNode, GraphEdge, GraphParticle } from './types';
|
import type { GraphNode, GraphEdge, GraphParticle, GraphLayoutPort } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data provider port — supplies graph state to the visualization.
|
* Data provider port — supplies graph state to the visualization.
|
||||||
|
|
@ -17,4 +17,6 @@ export interface GraphDataPort {
|
||||||
teamColor?: string;
|
teamColor?: string;
|
||||||
/** Whether the team lead process is alive */
|
/** Whether the team lead process is alive */
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
|
/** Stable owner-slot layout hints supplied by the host app */
|
||||||
|
layout?: GraphLayoutPort;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@ export type {
|
||||||
GraphNode,
|
GraphNode,
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
GraphParticle,
|
GraphParticle,
|
||||||
|
GraphActivityItem,
|
||||||
GraphNodeKind,
|
GraphNodeKind,
|
||||||
GraphNodeState,
|
GraphNodeState,
|
||||||
GraphEdgeType,
|
GraphEdgeType,
|
||||||
GraphParticleKind,
|
GraphParticleKind,
|
||||||
GraphDomainRef,
|
GraphDomainRef,
|
||||||
|
GraphOwnerSlotAssignment,
|
||||||
|
GraphLayoutPort,
|
||||||
|
GraphLayoutVersion,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,19 @@ export interface GraphActivityItem {
|
||||||
authorLabel?: string;
|
authorLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GraphLayoutVersion = 'stable-slots-v1';
|
||||||
|
|
||||||
|
export interface GraphOwnerSlotAssignment {
|
||||||
|
ringIndex: number;
|
||||||
|
sectorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLayoutPort {
|
||||||
|
version: GraphLayoutVersion;
|
||||||
|
ownerOrder: string[];
|
||||||
|
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Graph Node ──────────────────────────────────────────────────────────────
|
// ─── Graph Node ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
|
|
@ -71,6 +84,8 @@ export interface GraphNode {
|
||||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||||
/** Shared launch-stage visual derived by the host app */
|
/** Shared launch-stage visual derived by the host app */
|
||||||
launchVisualState?: GraphLaunchVisualState;
|
launchVisualState?: GraphLaunchVisualState;
|
||||||
|
/** Shared launch-stage text shown beside the node during launch only */
|
||||||
|
launchStatusLabel?: string;
|
||||||
/** Context window usage ratio (0..1), available for lead only */
|
/** Context window usage ratio (0..1), available for lead only */
|
||||||
contextUsage?: number;
|
contextUsage?: number;
|
||||||
/** Current task ID this member is working on */
|
/** Current task ID this member is working on */
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
||||||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||||
import { drawProcesses } from '../canvas/draw-processes';
|
import { drawProcesses } from '../canvas/draw-processes';
|
||||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||||
|
import { drawHexagon } from '../canvas/draw-misc';
|
||||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +37,7 @@ import {
|
||||||
updateTransientHandoffState,
|
updateTransientHandoffState,
|
||||||
} from './transientHandoffs';
|
} from './transientHandoffs';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
import { NODE } from '../constants/canvas-constants';
|
||||||
|
|
||||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||||
|
|
||||||
|
|
@ -53,6 +55,14 @@ export interface GraphDrawState {
|
||||||
hoveredEdgeId: string | null;
|
hoveredEdgeId: string | null;
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
focusEdgeIds: ReadonlySet<string> | null;
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
|
dragPreview:
|
||||||
|
| {
|
||||||
|
nodeId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphCanvasHandle {
|
export interface GraphCanvasHandle {
|
||||||
|
|
@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
state.focusNodeIds,
|
state.focusNodeIds,
|
||||||
zoom
|
zoom
|
||||||
);
|
);
|
||||||
|
if (state.dragPreview) {
|
||||||
|
drawOwnerSlotPreview(ctx, state.dragPreview, state.time);
|
||||||
|
}
|
||||||
|
|
||||||
// 2d. Effects
|
// 2d. Effects
|
||||||
drawEffects(ctx, state.effects);
|
drawEffects(ctx, state.effects);
|
||||||
|
|
@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function drawOwnerSlotPreview(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
preview: NonNullable<GraphDrawState['dragPreview']>,
|
||||||
|
time: number
|
||||||
|
): void {
|
||||||
|
const radius = NODE.radiusMember;
|
||||||
|
const outerRadius = radius + 18;
|
||||||
|
const innerRadius = radius + 8;
|
||||||
|
const glowRadius = radius + 34;
|
||||||
|
const color = preview.color ?? '#8bd3ff';
|
||||||
|
const pulse = 0.35 + 0.15 * Math.sin(time * 6);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.7 + pulse;
|
||||||
|
ctx.setLineDash([8, 6]);
|
||||||
|
ctx.lineDashOffset = -time * 48;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
|
||||||
|
drawHexagon(ctx, preview.x, preview.y, outerRadius);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
drawHexagon(ctx, preview.x, preview.y, innerRadius);
|
||||||
|
ctx.fillStyle = 'rgba(120, 190, 255, 0.08)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const glow = ctx.createRadialGradient(
|
||||||
|
preview.x,
|
||||||
|
preview.y,
|
||||||
|
radius * 0.45,
|
||||||
|
preview.x,
|
||||||
|
preview.y,
|
||||||
|
glowRadius
|
||||||
|
);
|
||||||
|
glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)');
|
||||||
|
glow.addColorStop(1, 'rgba(120, 190, 255, 0)');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Pause,
|
Pause,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
Pin,
|
Pin,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -41,9 +43,13 @@ export interface GraphControlsProps {
|
||||||
onRequestFullscreen?: () => void;
|
onRequestFullscreen?: () => void;
|
||||||
onOpenTeamPage?: () => void;
|
onOpenTeamPage?: () => void;
|
||||||
onCreateTask?: () => void;
|
onCreateTask?: () => void;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
isSidebarVisible?: boolean;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamColor?: string;
|
teamColor?: string;
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
|
topToolbarContent?: React.ReactNode;
|
||||||
|
interactionLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOPBAR_BUTTON_SIZE = 25;
|
const TOPBAR_BUTTON_SIZE = 25;
|
||||||
|
|
@ -60,7 +66,11 @@ export function GraphControls({
|
||||||
onRequestFullscreen,
|
onRequestFullscreen,
|
||||||
onOpenTeamPage,
|
onOpenTeamPage,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
|
onToggleSidebar,
|
||||||
|
isSidebarVisible = true,
|
||||||
teamColor,
|
teamColor,
|
||||||
|
topToolbarContent,
|
||||||
|
interactionLocked = false,
|
||||||
}: GraphControlsProps): React.JSX.Element {
|
}: GraphControlsProps): React.JSX.Element {
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const settingsRef = useRef<HTMLDivElement>(null);
|
const settingsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -96,141 +106,176 @@ export function GraphControls({
|
||||||
}, [isSettingsOpen]);
|
}, [isSettingsOpen]);
|
||||||
|
|
||||||
const nameColor = teamColor ?? '#aaeeff';
|
const nameColor = teamColor ?? '#aaeeff';
|
||||||
|
const chromeInteractivityClass = interactionLocked
|
||||||
|
? 'pointer-events-none select-none'
|
||||||
|
: 'pointer-events-auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 h-8">
|
||||||
{onOpenTeamPage ? (
|
<div className="absolute left-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
{onToggleSidebar ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onOpenTeamPage}
|
<ToolbarButton
|
||||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
onClick={onToggleSidebar}
|
||||||
toolbar
|
icon={
|
||||||
title="Open team page"
|
isSidebarVisible ? (
|
||||||
/>
|
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
||||||
</div>
|
) : (
|
||||||
) : null}
|
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
||||||
{onCreateTask ? (
|
)
|
||||||
<div
|
}
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
toolbar
|
||||||
style={{
|
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
/>
|
||||||
border: `1px solid ${nameColor}25`,
|
</div>
|
||||||
}}
|
) : null}
|
||||||
>
|
{onOpenTeamPage ? (
|
||||||
<ToolbarButton
|
<div
|
||||||
onClick={onCreateTask}
|
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
style={{
|
||||||
toolbar
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
title="Create task"
|
border: `1px solid ${nameColor}25`,
|
||||||
/>
|
}}
|
||||||
</div>
|
>
|
||||||
) : null}
|
<ToolbarButton
|
||||||
</div>
|
onClick={onOpenTeamPage}
|
||||||
|
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
toolbar
|
||||||
<div
|
title="Open team page"
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
/>
|
||||||
style={{
|
</div>
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
) : null}
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
{onCreateTask ? (
|
||||||
}}
|
<div
|
||||||
>
|
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||||
<ToolbarButton
|
style={{
|
||||||
onClick={() => toggle('paused')}
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
border: `1px solid ${nameColor}25`,
|
||||||
toolbar
|
}}
|
||||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
>
|
||||||
/>
|
<ToolbarButton
|
||||||
|
onClick={onCreateTask}
|
||||||
|
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Create task"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={settingsRef} className="relative pointer-events-auto">
|
<div className="absolute left-1/2 top-0 w-[min(360px,38vw)] -translate-x-1/2 px-2">
|
||||||
|
{topToolbarContent ? (
|
||||||
|
<div className={`${chromeInteractivityClass} min-w-0`}>
|
||||||
|
{topToolbarContent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute right-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
onClick={() => toggle('paused')}
|
||||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||||
active={isSettingsOpen}
|
|
||||||
toolbar
|
toolbar
|
||||||
title="Graph settings"
|
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSettingsOpen && (
|
<div ref={settingsRef} className={`relative ${chromeInteractivityClass}`}>
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.96)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.12)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarToggle
|
<ToolbarButton
|
||||||
active={filters.showTasks}
|
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||||
onClick={() => toggle('showTasks')}
|
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||||
icon={<Columns3 size={13} />}
|
active={isSettingsOpen}
|
||||||
label="Tasks"
|
toolbar
|
||||||
block
|
title="Graph settings"
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showProcesses}
|
|
||||||
onClick={() => toggle('showProcesses')}
|
|
||||||
icon={<Server size={13} />}
|
|
||||||
label="Processes"
|
|
||||||
block
|
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showEdges}
|
|
||||||
onClick={() => toggle('showEdges')}
|
|
||||||
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
||||||
label="Edges"
|
|
||||||
block
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{isSettingsOpen && (
|
||||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
background: 'rgba(8, 12, 24, 0.96)',
|
||||||
}}
|
border: '1px solid rgba(100, 200, 255, 0.12)',
|
||||||
>
|
}}
|
||||||
{onRequestPinAsTab && (
|
>
|
||||||
<ToolbarButton
|
<ToolbarToggle
|
||||||
onClick={onRequestPinAsTab}
|
active={filters.showTasks}
|
||||||
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
onClick={() => toggle('showTasks')}
|
||||||
toolbar
|
icon={<Columns3 size={13} />}
|
||||||
title="Pin as tab"
|
label="Tasks"
|
||||||
/>
|
block
|
||||||
)}
|
/>
|
||||||
{onRequestFullscreen && (
|
<ToolbarToggle
|
||||||
<ToolbarButton
|
active={filters.showProcesses}
|
||||||
onClick={onRequestFullscreen}
|
onClick={() => toggle('showProcesses')}
|
||||||
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
icon={<Server size={13} />}
|
||||||
toolbar
|
label="Processes"
|
||||||
title="Fullscreen"
|
block
|
||||||
/>
|
/>
|
||||||
)}
|
<ToolbarToggle
|
||||||
{onRequestClose && (
|
active={filters.showEdges}
|
||||||
<ToolbarButton
|
onClick={() => toggle('showEdges')}
|
||||||
onClick={onRequestClose}
|
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||||
icon={<X size={TOPBAR_ICON_SIZE} />}
|
label="Edges"
|
||||||
toolbar
|
block
|
||||||
title="Close graph"
|
/>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${chromeInteractivityClass} flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm`}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onRequestPinAsTab && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestPinAsTab}
|
||||||
|
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Pin as tab"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestFullscreen && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestFullscreen}
|
||||||
|
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Fullscreen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestClose && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestClose}
|
||||||
|
icon={<X size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Close graph"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
|
||||||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||||
import type { GraphEdge, GraphNode } from '../ports/types';
|
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||||
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
|
import type { StableRect } from '../layout/stableSlots';
|
||||||
|
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||||
import { GraphOverlay } from './GraphOverlay';
|
import { GraphOverlay } from './GraphOverlay';
|
||||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||||
|
|
@ -31,7 +32,6 @@ import {
|
||||||
getEdgeMidpoint,
|
getEdgeMidpoint,
|
||||||
} from '../canvas/hit-detection';
|
} from '../canvas/hit-detection';
|
||||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
import { ANIM_SPEED } from '../constants/canvas-constants';
|
||||||
import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane';
|
|
||||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
|
@ -43,8 +43,18 @@ export interface GraphViewProps {
|
||||||
onRequestClose?: () => void;
|
onRequestClose?: () => void;
|
||||||
onRequestPinAsTab?: () => void;
|
onRequestPinAsTab?: () => void;
|
||||||
onRequestFullscreen?: () => void;
|
onRequestFullscreen?: () => void;
|
||||||
|
isSurfaceActive?: boolean;
|
||||||
onOpenTeamPage?: () => void;
|
onOpenTeamPage?: () => void;
|
||||||
onCreateTask?: () => void;
|
onCreateTask?: () => void;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
isSidebarVisible?: boolean;
|
||||||
|
renderTopToolbarContent?: () => React.ReactNode;
|
||||||
|
onOwnerSlotDrop?: (payload: {
|
||||||
|
nodeId: string;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
displacedNodeId?: string;
|
||||||
|
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||||
|
}) => void;
|
||||||
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
|
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
|
||||||
renderOverlay?: (props: {
|
renderOverlay?: (props: {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
|
|
@ -62,12 +72,11 @@ export interface GraphViewProps {
|
||||||
getLaunchAnchorScreenPlacement: (
|
getLaunchAnchorScreenPlacement: (
|
||||||
leadNodeId: string,
|
leadNodeId: string,
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||||
getActivityAnchorScreenPlacement: (
|
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||||
ownerNodeId: string,
|
getCameraZoom: () => number;
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeScreenPosition: (
|
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
||||||
nodeId: string,
|
getViewportSize: () => { width: number; height: number };
|
||||||
) => { x: number; y: number; visible: boolean } | null;
|
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -81,8 +90,13 @@ export function GraphView({
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onRequestPinAsTab,
|
onRequestPinAsTab,
|
||||||
onRequestFullscreen,
|
onRequestFullscreen,
|
||||||
|
isSurfaceActive = true,
|
||||||
onOpenTeamPage,
|
onOpenTeamPage,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
|
onToggleSidebar,
|
||||||
|
isSidebarVisible = true,
|
||||||
|
renderTopToolbarContent,
|
||||||
|
onOwnerSlotDrop,
|
||||||
renderOverlay,
|
renderOverlay,
|
||||||
renderEdgeOverlay,
|
renderEdgeOverlay,
|
||||||
renderHud,
|
renderHud,
|
||||||
|
|
@ -90,6 +104,7 @@ export function GraphView({
|
||||||
// ─── React state (user-facing only) ─────────────────────────────────────
|
// ─── React state (user-facing only) ─────────────────────────────────────
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
const [interactionLocked, setInteractionLocked] = useState(false);
|
||||||
const [filters, setFilters] = useState<GraphFilterState>({
|
const [filters, setFilters] = useState<GraphFilterState>({
|
||||||
showTasks: config?.showTasks ?? true,
|
showTasks: config?.showTasks ?? true,
|
||||||
showProcesses: config?.showProcesses ?? true,
|
showProcesses: config?.showProcesses ?? true,
|
||||||
|
|
@ -115,6 +130,14 @@ export function GraphView({
|
||||||
const allowAutoFitRef = useRef(true);
|
const allowAutoFitRef = useRef(true);
|
||||||
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
||||||
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
||||||
|
const dragPreviewRef = useRef<{
|
||||||
|
nodeId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color?: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
|
||||||
|
const activePrimaryInteractionRef = useRef(false);
|
||||||
|
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
|
|
@ -135,18 +158,31 @@ export function GraphView({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getVisibleNodes = useCallback(
|
||||||
|
(nodes: GraphNode[]): GraphNode[] =>
|
||||||
|
nodes.filter((node) => {
|
||||||
|
if (node.kind === 'task' && !filters.showTasks) return false;
|
||||||
|
if (node.kind === 'process' && !filters.showProcesses) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[filters.showProcesses, filters.showTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getVisibleEdges = useCallback(
|
||||||
|
(edges: GraphEdge[], visibleNodeIds: ReadonlySet<string>): GraphEdge[] =>
|
||||||
|
edges.filter((edge) => {
|
||||||
|
if (!filters.showEdges && edge.type !== 'parent-child') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
|
||||||
|
}),
|
||||||
|
[filters.showEdges]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Sync data from adapter → simulation ────────────────────────────────
|
// ─── Sync data from adapter → simulation ────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filteredNodes = data.nodes.filter((n) => {
|
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
|
||||||
if (n.kind === 'task' && !filters.showTasks) return false;
|
}, [data, simulation]);
|
||||||
if (n.kind === 'process' && !filters.showProcesses) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const filteredEdges = filters.showEdges
|
|
||||||
? data.edges
|
|
||||||
: data.edges.filter((e) => e.type === 'parent-child');
|
|
||||||
simulation.updateData(filteredNodes, filteredEdges, data.particles);
|
|
||||||
}, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]);
|
|
||||||
|
|
||||||
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
||||||
const focusState = useMemo(
|
const focusState = useMemo(
|
||||||
|
|
@ -209,44 +245,51 @@ export function GraphView({
|
||||||
viewportHeight: viewport.height,
|
viewportHeight: viewport.height,
|
||||||
});
|
});
|
||||||
}, [getViewportSize]);
|
}, [getViewportSize]);
|
||||||
const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => {
|
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
||||||
const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId);
|
const getActivityWorldRect = useCallback(
|
||||||
if (!anchor) {
|
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||||
return null;
|
[]
|
||||||
}
|
);
|
||||||
const viewport = getViewportSize();
|
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const transform = cameraRef.current.transformRef.current;
|
|
||||||
return buildActivityAnchorScreenPlacement({
|
|
||||||
anchorX: anchor.x,
|
|
||||||
anchorY: anchor.y,
|
|
||||||
cameraX: transform.x,
|
|
||||||
cameraY: transform.y,
|
|
||||||
zoom: transform.zoom,
|
|
||||||
viewportWidth: viewport.width,
|
|
||||||
viewportHeight: viewport.height,
|
|
||||||
});
|
|
||||||
}, [getViewportSize]);
|
|
||||||
const getNodeScreenPosition = useCallback((nodeId: string) => {
|
|
||||||
const viewport = getViewportSize();
|
|
||||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||||
if (!node || node.x == null || node.y == null) {
|
if (node?.x == null || node?.y == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const transform = cameraRef.current.transformRef.current;
|
return { x: node.x, y: node.y };
|
||||||
const x = node.x * transform.zoom + transform.x;
|
}, []);
|
||||||
const y = node.y * transform.zoom + transform.y;
|
|
||||||
return {
|
const setInteractionSelectionDisabled = useCallback((disabled: boolean) => {
|
||||||
x,
|
if (typeof document === 'undefined') {
|
||||||
y,
|
return;
|
||||||
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
|
}
|
||||||
};
|
const bodyStyle = document.body.style;
|
||||||
}, [getViewportSize]);
|
if (disabled) {
|
||||||
|
if (!selectionLockRef.current) {
|
||||||
|
selectionLockRef.current = {
|
||||||
|
userSelect: bodyStyle.userSelect,
|
||||||
|
webkitUserSelect: bodyStyle.webkitUserSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bodyStyle.userSelect = 'none';
|
||||||
|
bodyStyle.webkitUserSelect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectionLockRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bodyStyle.userSelect = selectionLockRef.current.userSelect;
|
||||||
|
bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect;
|
||||||
|
selectionLockRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setInteractionGuards = useCallback(
|
||||||
|
(active: boolean) => {
|
||||||
|
activePrimaryInteractionRef.current = active;
|
||||||
|
setInteractionLocked(active);
|
||||||
|
setInteractionSelectionDisabled(active);
|
||||||
|
},
|
||||||
|
[setInteractionSelectionDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
const animate = useCallback(() => {
|
const animate = useCallback(() => {
|
||||||
if (!runningRef.current) return;
|
if (!runningRef.current) return;
|
||||||
|
|
@ -266,12 +309,15 @@ export function GraphView({
|
||||||
|
|
||||||
// 3. Draw every frame: background stars and shooting stars need continuous motion.
|
// 3. Draw every frame: background stars and shooting stars need continuous motion.
|
||||||
const state = simulationRef.current.stateRef.current;
|
const state = simulationRef.current.stateRef.current;
|
||||||
|
const visibleNodes = getVisibleNodes(state.nodes);
|
||||||
|
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
|
||||||
|
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds);
|
||||||
|
|
||||||
// 4. Draw canvas imperatively (NO React re-render)
|
// 4. Draw canvas imperatively (NO React re-render)
|
||||||
canvasHandle.current?.draw({
|
canvasHandle.current?.draw({
|
||||||
teamName: data.teamName,
|
teamName: data.teamName,
|
||||||
nodes: state.nodes,
|
nodes: visibleNodes,
|
||||||
edges: state.edges,
|
edges: visibleEdges,
|
||||||
particles: state.particles,
|
particles: state.particles,
|
||||||
effects: state.effects,
|
effects: state.effects,
|
||||||
time: state.time,
|
time: state.time,
|
||||||
|
|
@ -282,11 +328,18 @@ export function GraphView({
|
||||||
hoveredEdgeId: hoveredEdgeIdRef.current,
|
hoveredEdgeId: hoveredEdgeIdRef.current,
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
focusEdgeIds: focusState.focusEdgeIds,
|
focusEdgeIds: focusState.focusEdgeIds,
|
||||||
|
dragPreview: dragPreviewRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(animate);
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
|
}, [
|
||||||
}, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]);
|
data.teamName,
|
||||||
|
focusState.focusEdgeIds,
|
||||||
|
focusState.focusNodeIds,
|
||||||
|
getVisibleEdges,
|
||||||
|
getVisibleNodes,
|
||||||
|
interaction.hoveredNodeId,
|
||||||
|
]);
|
||||||
|
|
||||||
// Start/stop RAF
|
// Start/stop RAF
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -362,6 +415,18 @@ export function GraphView({
|
||||||
allowAutoFitRef.current = false;
|
allowAutoFitRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isSurfaceActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
interaction.handleMouseUp();
|
||||||
|
simulation.clearTransientOwnerPositions();
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
isPanningRef.current = false;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
setInteractionGuards(false);
|
||||||
|
}, [interaction, isSurfaceActive, simulation]);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
|
|
@ -377,13 +442,20 @@ export function GraphView({
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return; // only left click
|
if (e.button !== 0) return; // only left click
|
||||||
|
e.preventDefault();
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
setInteractionGuards(true);
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) {
|
||||||
|
setInteractionGuards(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
const nodes = simulation.stateRef.current.nodes;
|
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
||||||
const edges = simulation.stateRef.current.edges;
|
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
||||||
|
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
||||||
const nodeMap = getNodeMap(nodes);
|
const nodeMap = getNodeMap(nodes);
|
||||||
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
||||||
|
|
||||||
|
|
@ -414,62 +486,119 @@ export function GraphView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef]
|
[
|
||||||
|
camera,
|
||||||
|
getInteractiveEdges,
|
||||||
|
getNodeMap,
|
||||||
|
getVisibleEdges,
|
||||||
|
getVisibleNodes,
|
||||||
|
interaction,
|
||||||
|
markUserInteracted,
|
||||||
|
setInteractionGuards,
|
||||||
|
simulation.stateRef,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const processActivePointerMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(clientX: number, clientY: number) => {
|
||||||
// Dragging with left button held
|
if (!activePrimaryInteractionRef.current) {
|
||||||
if (e.buttons & 1) {
|
dragPreviewRef.current = null;
|
||||||
if (isPanningRef.current) {
|
return false;
|
||||||
camera.handlePanMove(e.clientX, e.clientY);
|
}
|
||||||
return;
|
|
||||||
|
if (isPanningRef.current) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
}
|
}
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
camera.handlePanMove(clientX, clientY);
|
||||||
if (!canvas) return;
|
return true;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
||||||
interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No button held — hover detection + cursor update
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
dragPreviewRef.current = null;
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
return false;
|
||||||
const nodes = simulation.stateRef.current.nodes;
|
|
||||||
const edges = simulation.stateRef.current.edges;
|
|
||||||
|
|
||||||
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
|
|
||||||
interaction.hoveredNodeId.current = hoveredNodeId;
|
|
||||||
|
|
||||||
if (hoveredNodeId) {
|
|
||||||
hoveredEdgeIdRef.current = null;
|
|
||||||
canvas.style.cursor = 'pointer';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeMap = getNodeMap(nodes);
|
const rect = canvas.getBoundingClientRect();
|
||||||
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||||
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
|
||||||
|
const draggedNodeId = interaction.dragNodeId.current;
|
||||||
|
if (interaction.isDragging.current && draggedNodeId) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||||
|
if (draggedNode?.kind === 'member') {
|
||||||
|
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
||||||
|
if (nearest) {
|
||||||
|
dragPreviewRef.current = {
|
||||||
|
nodeId: draggedNodeId,
|
||||||
|
x: nearest.previewOwnerX,
|
||||||
|
y: nearest.previewOwnerY,
|
||||||
|
color: draggedNode.color,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef]
|
[camera, getVisibleNodes, interaction, simulation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(
|
const completePointerInteraction = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(clientX: number, clientY: number) => {
|
||||||
|
const draggedNodeId = interaction.dragNodeId.current;
|
||||||
|
const wasDragging = interaction.isDragging.current;
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
camera.handlePanEnd();
|
camera.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
setSelectedNodeId(null); // hide popover after pan
|
setInteractionGuards(false);
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
|
interaction.handleMouseUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickedId = interaction.handleMouseUp();
|
const clickedId = interaction.handleMouseUp();
|
||||||
|
if (wasDragging && draggedNodeId) {
|
||||||
|
setInteractionGuards(false);
|
||||||
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||||
|
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
|
||||||
|
const nearest = simulation.resolveNearestOwnerSlot(
|
||||||
|
draggedNodeId,
|
||||||
|
draggedNode.x,
|
||||||
|
draggedNode.y
|
||||||
|
);
|
||||||
|
if (nearest) {
|
||||||
|
onOwnerSlotDrop?.({
|
||||||
|
nodeId: draggedNodeId,
|
||||||
|
assignment: nearest.assignment,
|
||||||
|
displacedNodeId: nearest.displacedOwnerId,
|
||||||
|
displacedAssignment: nearest.displacedAssignment,
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
simulation.clearNodePosition(draggedNodeId);
|
||||||
|
});
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
simulation.clearNodePosition(draggedNodeId);
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractionGuards(false);
|
||||||
if (clickedId) {
|
if (clickedId) {
|
||||||
setSelectedNodeId(clickedId);
|
setSelectedNodeId(clickedId);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
|
|
@ -480,7 +609,7 @@ export function GraphView({
|
||||||
let clickedEdgeId: string | null = null;
|
let clickedEdgeId: string | null = null;
|
||||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
const dx = world.x - edgeMouseDownRef.current.x;
|
const dx = world.x - edgeMouseDownRef.current.x;
|
||||||
const dy = world.y - edgeMouseDownRef.current.y;
|
const dy = world.y - edgeMouseDownRef.current.y;
|
||||||
if (dx * dx + dy * dy <= 25) {
|
if (dx * dx + dy * dy <= 25) {
|
||||||
|
|
@ -499,17 +628,121 @@ export function GraphView({
|
||||||
events?.onEdgeClick?.(edge);
|
events?.onEdgeClick?.(edge);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedNodeId(null); // click on empty space — hide popover
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
}
|
}
|
||||||
if (!interaction.isDragging.current && !clickedEdgeId) {
|
if (!interaction.isDragging.current && !clickedEdgeId) {
|
||||||
events?.onBackgroundClick?.();
|
events?.onBackgroundClick?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
dragPreviewRef.current = null;
|
||||||
},
|
},
|
||||||
[interaction, simulation.stateRef, events, camera, data.teamName]
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (processActivePointerMove(e.clientX, e.clientY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
|
||||||
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
||||||
|
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
||||||
|
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
||||||
|
|
||||||
|
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
|
||||||
|
interaction.hoveredNodeId.current = hoveredNodeId;
|
||||||
|
|
||||||
|
if (hoveredNodeId) {
|
||||||
|
hoveredEdgeIdRef.current = null;
|
||||||
|
canvas.style.cursor = 'pointer';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = getNodeMap(nodes);
|
||||||
|
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
||||||
|
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
||||||
|
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
||||||
|
},
|
||||||
|
[
|
||||||
|
camera,
|
||||||
|
getInteractiveEdges,
|
||||||
|
getNodeMap,
|
||||||
|
getVisibleEdges,
|
||||||
|
getVisibleNodes,
|
||||||
|
interaction,
|
||||||
|
processActivePointerMove,
|
||||||
|
simulation.stateRef,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
completePointerInteraction(e.clientX, e.clientY);
|
||||||
|
},
|
||||||
|
[completePointerInteraction]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowMouseMove = (event: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!isPanningRef.current &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current &&
|
||||||
|
!edgeMouseDownRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
processActivePointerMove(event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!isPanningRef.current &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current &&
|
||||||
|
!edgeMouseDownRef.current
|
||||||
|
) {
|
||||||
|
setInteractionGuards(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
completePointerInteraction(event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInteraction = (): void => {
|
||||||
|
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
interaction.handleMouseUp();
|
||||||
|
camera.handlePanEnd();
|
||||||
|
isPanningRef.current = false;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
setInteractionGuards(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleWindowMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleWindowMouseUp);
|
||||||
|
window.addEventListener('blur', clearInteraction);
|
||||||
|
window.addEventListener('dragstart', clearInteraction);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleWindowMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||||
|
window.removeEventListener('blur', clearInteraction);
|
||||||
|
window.removeEventListener('dragstart', clearInteraction);
|
||||||
|
setInteractionGuards(false);
|
||||||
|
};
|
||||||
|
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
|
|
@ -519,7 +752,7 @@ export function GraphView({
|
||||||
const nodeId = interaction.handleDoubleClick(
|
const nodeId = interaction.handleDoubleClick(
|
||||||
world.x,
|
world.x,
|
||||||
world.y,
|
world.y,
|
||||||
simulation.stateRef.current.nodes
|
getVisibleNodes(simulation.stateRef.current.nodes)
|
||||||
);
|
);
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
|
|
@ -534,7 +767,7 @@ export function GraphView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[camera, interaction, simulation.stateRef, events]
|
[camera, events, getVisibleNodes, interaction, simulation.stateRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Keyboard ───────────────────────────────────────────────────────────
|
// ─── Keyboard ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -579,10 +812,6 @@ export function GraphView({
|
||||||
const selectedEdge: GraphEdge | null = selectedEdgeId
|
const selectedEdge: GraphEdge | null = selectedEdgeId
|
||||||
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
|
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const hasBlockingEdges = useMemo(
|
|
||||||
() => data.edges.some((edge) => edge.type === 'blocking'),
|
|
||||||
[data.edges]
|
|
||||||
);
|
|
||||||
const selectedEdgeNodeMap = useMemo(
|
const selectedEdgeNodeMap = useMemo(
|
||||||
() => getNodeMap(simulation.stateRef.current.nodes),
|
() => getNodeMap(simulation.stateRef.current.nodes),
|
||||||
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
|
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
|
||||||
|
|
@ -660,7 +889,10 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative h-full w-full overflow-hidden select-none ${className ?? ''}`}
|
||||||
|
>
|
||||||
<GraphCanvas
|
<GraphCanvas
|
||||||
ref={canvasHandle}
|
ref={canvasHandle}
|
||||||
showHexGrid={config?.showHexGrid ?? true}
|
showHexGrid={config?.showHexGrid ?? true}
|
||||||
|
|
@ -700,17 +932,24 @@ export function GraphView({
|
||||||
onRequestFullscreen={onRequestFullscreen}
|
onRequestFullscreen={onRequestFullscreen}
|
||||||
onOpenTeamPage={onOpenTeamPage}
|
onOpenTeamPage={onOpenTeamPage}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
|
onToggleSidebar={onToggleSidebar}
|
||||||
|
isSidebarVisible={isSidebarVisible}
|
||||||
teamName={data.teamName}
|
teamName={data.teamName}
|
||||||
teamColor={data.teamColor}
|
teamColor={data.teamColor}
|
||||||
isAlive={data.isAlive}
|
isAlive={data.isAlive}
|
||||||
|
topToolbarContent={renderTopToolbarContent?.()}
|
||||||
|
interactionLocked={interactionLocked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderHud ? (
|
{renderHud ? (
|
||||||
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
||||||
{renderHud({
|
{renderHud({
|
||||||
getLaunchAnchorScreenPlacement,
|
getLaunchAnchorScreenPlacement,
|
||||||
getActivityAnchorScreenPlacement,
|
getActivityWorldRect,
|
||||||
getNodeScreenPosition,
|
getCameraZoom,
|
||||||
|
worldToScreen: camera.worldToScreen,
|
||||||
|
getNodeWorldPosition,
|
||||||
|
getViewportSize,
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"supports_native_structured_output": true
|
"supports_native_structured_output": true,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-6-v1": {
|
"global.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -338,7 +339,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"supports_native_structured_output": true
|
"supports_native_structured_output": true,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-opus-4-6-v1": {
|
"us.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -365,7 +367,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"supports_native_structured_output": true
|
"supports_native_structured_output": true,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"eu.anthropic.claude-opus-4-6-v1": {
|
"eu.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -392,7 +395,8 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"supports_native_structured_output": true
|
"supports_native_structured_output": true,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"au.anthropic.claude-opus-4-6-v1": {
|
"au.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
|
@ -419,6 +423,147 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_native_structured_output": true,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
|
},
|
||||||
|
"anthropic.claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "bedrock_converse",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_native_structured_output": true
|
||||||
|
},
|
||||||
|
"global.anthropic.claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "bedrock_converse",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_native_structured_output": true
|
||||||
|
},
|
||||||
|
"us.anthropic.claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
|
"input_cost_per_token": 0.0000055,
|
||||||
|
"litellm_provider": "bedrock_converse",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.0000275,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_native_structured_output": true
|
||||||
|
},
|
||||||
|
"eu.anthropic.claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
|
"input_cost_per_token": 0.0000055,
|
||||||
|
"litellm_provider": "bedrock_converse",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.0000275,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_native_structured_output": true
|
||||||
|
},
|
||||||
|
"au.anthropic.claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
|
"input_cost_per_token": 0.0000055,
|
||||||
|
"litellm_provider": "bedrock_converse",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.0000275,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"supports_native_structured_output": true
|
"supports_native_structured_output": true
|
||||||
},
|
},
|
||||||
"anthropic.claude-sonnet-4-6": {
|
"anthropic.claude-sonnet-4-6": {
|
||||||
|
|
@ -854,6 +999,35 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
"tool_use_system_prompt_tokens": 159,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
|
},
|
||||||
|
"azure_ai/claude-opus-4-7": {
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"litellm_provider": "azure_ai",
|
||||||
|
"max_input_tokens": 200000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
"tool_use_system_prompt_tokens": 159
|
"tool_use_system_prompt_tokens": 159
|
||||||
},
|
},
|
||||||
"azure_ai/claude-opus-4-1": {
|
"azure_ai/claude-opus-4-1": {
|
||||||
|
|
@ -1687,7 +1861,8 @@
|
||||||
"provider_specific_entry": {
|
"provider_specific_entry": {
|
||||||
"us": 1.1,
|
"us": 1.1,
|
||||||
"fast": 6
|
"fast": 6
|
||||||
}
|
},
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"claude-opus-4-6-20260205": {
|
"claude-opus-4-6-20260205": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -1715,6 +1890,71 @@
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"provider_specific_entry": {
|
||||||
|
"us": 1.1,
|
||||||
|
"fast": 6
|
||||||
|
},
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
|
},
|
||||||
|
"claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "anthropic",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"provider_specific_entry": {
|
||||||
|
"us": 1.1,
|
||||||
|
"fast": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"claude-opus-4-7-20260416": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "anthropic",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
"provider_specific_entry": {
|
"provider_specific_entry": {
|
||||||
"us": 1.1,
|
"us": 1.1,
|
||||||
"fast": 6
|
"fast": 6
|
||||||
|
|
@ -4148,7 +4388,8 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"vertex_ai/claude-opus-4-6@default": {
|
"vertex_ai/claude-opus-4-6@default": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
|
@ -4174,6 +4415,61 @@
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
"supports_max_reasoning_effort": true
|
||||||
|
},
|
||||||
|
"vertex_ai/claude-opus-4-7": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "vertex_ai-anthropic_models",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
"tool_use_system_prompt_tokens": 346
|
||||||
|
},
|
||||||
|
"vertex_ai/claude-opus-4-7@default": {
|
||||||
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_read_input_token_cost": 5e-7,
|
||||||
|
"input_cost_per_token": 0.000005,
|
||||||
|
"litellm_provider": "vertex_ai-anthropic_models",
|
||||||
|
"max_input_tokens": 1000000,
|
||||||
|
"max_output_tokens": 128000,
|
||||||
|
"max_tokens": 128000,
|
||||||
|
"mode": "chat",
|
||||||
|
"output_cost_per_token": 0.000025,
|
||||||
|
"search_context_cost_per_query": {
|
||||||
|
"search_context_size_high": 0.01,
|
||||||
|
"search_context_size_low": 0.01,
|
||||||
|
"search_context_size_medium": 0.01
|
||||||
|
},
|
||||||
|
"supports_assistant_prefill": false,
|
||||||
|
"supports_computer_use": true,
|
||||||
|
"supports_function_calling": true,
|
||||||
|
"supports_pdf_input": true,
|
||||||
|
"supports_prompt_caching": true,
|
||||||
|
"supports_reasoning": true,
|
||||||
|
"supports_response_schema": true,
|
||||||
|
"supports_tool_choice": true,
|
||||||
|
"supports_vision": true,
|
||||||
|
"supports_xhigh_reasoning_effort": true,
|
||||||
"tool_use_system_prompt_tokens": 346
|
"tool_use_system_prompt_tokens": 346
|
||||||
},
|
},
|
||||||
"vertex_ai/claude-sonnet-4-5": {
|
"vertex_ai/claude-sonnet-4-5": {
|
||||||
|
|
|
||||||
76
scripts/dev-web.mjs
Normal file
76
scripts/dev-web.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
const standalonePort = process.env.STANDALONE_PORT?.trim() || '3456';
|
||||||
|
const webPort = process.env.WEB_PORT?.trim() || '5174';
|
||||||
|
const corsOrigin =
|
||||||
|
process.env.CORS_ORIGIN?.trim() ||
|
||||||
|
`http://127.0.0.1:${webPort},http://localhost:${webPort}`;
|
||||||
|
|
||||||
|
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||||
|
|
||||||
|
function shouldUseWindowsShell(cmd) {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WINDOWS_SHELL_COMMANDS.has(path.basename(cmd).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnProcess(cmd, args, env) {
|
||||||
|
return spawn(cmd, args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: shouldUseWindowsShell(cmd),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = spawnProcess('pnpm', ['exec', 'tsx', 'src/main/standalone.ts'], {
|
||||||
|
HOST: process.env.HOST?.trim() || '127.0.0.1',
|
||||||
|
PORT: standalonePort,
|
||||||
|
CORS_ORIGIN: corsOrigin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const frontend = spawnProcess(
|
||||||
|
'pnpm',
|
||||||
|
['exec', 'vite', '--config', 'vite.web.config.ts', '--host', '127.0.0.1', '--port', webPort],
|
||||||
|
{
|
||||||
|
VITE_STANDALONE_PORT: standalonePort,
|
||||||
|
VITE_WEB_PORT: webPort,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function terminateChildren(signal = 'SIGTERM') {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shuttingDown = true;
|
||||||
|
backend.kill(signal);
|
||||||
|
frontend.kill(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.on('exit', (code, signal) => {
|
||||||
|
terminateChildren(signal ?? undefined);
|
||||||
|
process.exitCode = code ?? (signal ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend.on('exit', (code, signal) => {
|
||||||
|
terminateChildren(signal ?? undefined);
|
||||||
|
process.exitCode = code ?? (signal ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => terminateChildren('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => terminateChildren('SIGTERM'));
|
||||||
|
|
||||||
|
console.log(`Starting standalone backend on http://127.0.0.1:${standalonePort}`);
|
||||||
|
console.log(`Starting browser dev server on http://127.0.0.1:${webPort}`);
|
||||||
24
src/features/README.md
Normal file
24
src/features/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Features
|
||||||
|
|
||||||
|
This directory contains the canonical home for medium and large feature slices.
|
||||||
|
|
||||||
|
Before creating or refactoring a feature, read:
|
||||||
|
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||||
|
- [Feature-local agent guidance](./CLAUDE.md)
|
||||||
|
|
||||||
|
Reference implementation:
|
||||||
|
- `src/features/recent-projects`
|
||||||
|
- `src/features/agent-graph`
|
||||||
|
|
||||||
|
Use `src/features/<feature-name>/` by default when the work introduces:
|
||||||
|
- a new use case or business policy
|
||||||
|
- transport wiring
|
||||||
|
- more than one process boundary
|
||||||
|
- more than one adapter or provider
|
||||||
|
|
||||||
|
Do not duplicate architecture rules in feature folders.
|
||||||
|
Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
|
||||||
|
|
||||||
|
Rule of thumb:
|
||||||
|
- `recent-projects` is the full slice example with process-aware outer layers
|
||||||
|
- `agent-graph` is the thin slice example built around `core/` plus `renderer/`
|
||||||
21
src/features/agent-graph/README.md
Normal file
21
src/features/agent-graph/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Agent Graph Feature
|
||||||
|
|
||||||
|
This feature is a thin renderer slice over the reusable graph engine in `packages/agent-graph`.
|
||||||
|
|
||||||
|
Read first:
|
||||||
|
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||||
|
- [Feature root guidance](../CLAUDE.md)
|
||||||
|
- [Stable Slot Layout Plan](./STABLE_SLOT_LAYOUT_PLAN.md)
|
||||||
|
|
||||||
|
Public entrypoint:
|
||||||
|
- `@features/agent-graph/renderer`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- `packages/agent-graph` owns reusable graph rendering and low-level graph mechanics
|
||||||
|
- `src/features/agent-graph/core/domain` owns project-specific graph semantics and pure projection helpers
|
||||||
|
- `src/features/agent-graph/renderer` owns the renderer integration layer, hooks, adapters, and UI
|
||||||
|
|
||||||
|
Use this feature as the thin-slice example when a feature:
|
||||||
|
- has no dedicated `main` or `preload` transport boundary
|
||||||
|
- integrates an existing reusable package into the app shell
|
||||||
|
- still needs its own feature boundary and public entrypoint
|
||||||
2846
src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md
Normal file
2846
src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,8 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity';
|
||||||
|
|
||||||
import type { GraphActivityItem } from '@claude-teams/agent-graph';
|
import type { GraphActivityItem } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
AttachmentMeta,
|
AttachmentMeta,
|
||||||
|
|
@ -18,6 +20,8 @@ export interface InlineActivityEntry {
|
||||||
ownerNodeId: string;
|
ownerNodeId: string;
|
||||||
graphItem: GraphActivityItem;
|
graphItem: GraphActivityItem;
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
|
sourceKind: 'message' | 'comment';
|
||||||
|
sourceOrder: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityEntrySourceData {
|
export interface ActivityEntrySourceData {
|
||||||
|
|
@ -49,6 +53,10 @@ export function buildInlineActivityEntries({
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
}: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> {
|
}: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> {
|
||||||
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
|
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
|
||||||
|
const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap(
|
||||||
|
teamName,
|
||||||
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
|
);
|
||||||
|
|
||||||
const appendEntry = (entry: InlineActivityEntry): void => {
|
const appendEntry = (entry: InlineActivityEntry): void => {
|
||||||
const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId;
|
const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId;
|
||||||
|
|
@ -65,6 +73,9 @@ export function buildInlineActivityEntries({
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedMessages = [...data.messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
const orderedMessages = [...data.messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
const messageSourceOrderByKey = new Map(
|
||||||
|
data.messages.map((message, index) => [getActivityMessageKey(message), index] as const)
|
||||||
|
);
|
||||||
for (const message of orderedMessages) {
|
for (const message of orderedMessages) {
|
||||||
if (message.summary?.startsWith('Comment on ')) {
|
if (message.summary?.startsWith('Comment on ')) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -80,10 +91,10 @@ export function buildInlineActivityEntries({
|
||||||
|
|
||||||
const ownerNodeId = resolveMessageOwnerNodeId({
|
const ownerNodeId = resolveMessageOwnerNodeId({
|
||||||
message,
|
message,
|
||||||
teamName,
|
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
|
memberNodeIdByAlias,
|
||||||
});
|
});
|
||||||
if (!ownerNodeId) {
|
if (!ownerNodeId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -113,6 +124,8 @@ export function buildInlineActivityEntries({
|
||||||
ownerNodeId,
|
ownerNodeId,
|
||||||
graphItem,
|
graphItem,
|
||||||
message,
|
message,
|
||||||
|
sourceKind: 'message',
|
||||||
|
sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,10 +136,10 @@ export function buildInlineActivityEntries({
|
||||||
const ownerNodeId = resolveCommentOwnerNodeId({
|
const ownerNodeId = resolveCommentOwnerNodeId({
|
||||||
taskOwner: item.task.owner,
|
taskOwner: item.task.owner,
|
||||||
author: item.comment.author,
|
author: item.comment.author,
|
||||||
teamName,
|
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
|
memberNodeIdByAlias,
|
||||||
});
|
});
|
||||||
if (!ownerNodeId) {
|
if (!ownerNodeId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -154,14 +167,13 @@ export function buildInlineActivityEntries({
|
||||||
task: item.task,
|
task: item.task,
|
||||||
comment: item.comment,
|
comment: item.comment,
|
||||||
}),
|
}),
|
||||||
|
sourceKind: 'comment',
|
||||||
|
sourceOrder: item.sourceOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [ownerNodeId, entries] of entriesByOwnerNodeId) {
|
for (const [ownerNodeId, entries] of entriesByOwnerNodeId) {
|
||||||
entriesByOwnerNodeId.set(
|
entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries));
|
||||||
ownerNodeId,
|
|
||||||
entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entriesByOwnerNodeId;
|
return entriesByOwnerNodeId;
|
||||||
|
|
@ -169,30 +181,55 @@ export function buildInlineActivityEntries({
|
||||||
|
|
||||||
function collectTaskComments(
|
function collectTaskComments(
|
||||||
tasks: readonly TeamTaskWithKanban[]
|
tasks: readonly TeamTaskWithKanban[]
|
||||||
): { task: TeamTaskWithKanban; comment: TaskComment }[] {
|
): { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] {
|
||||||
const items: { task: TeamTaskWithKanban; comment: TaskComment }[] = [];
|
const items: { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] = [];
|
||||||
|
let sourceOrder = 0;
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
for (const comment of task.comments ?? []) {
|
for (const comment of task.comments ?? []) {
|
||||||
items.push({ task, comment });
|
items.push({ task, comment, sourceOrder });
|
||||||
|
sourceOrder += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareInlineActivityEntries(
|
||||||
|
left: InlineActivityEntry,
|
||||||
|
right: InlineActivityEntry
|
||||||
|
): number {
|
||||||
|
const timestampDiff = right.graphItem.timestamp.localeCompare(left.graphItem.timestamp);
|
||||||
|
if (timestampDiff !== 0) {
|
||||||
|
return timestampDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
left.sourceKind === right.sourceKind &&
|
||||||
|
left.sourceOrder != null &&
|
||||||
|
right.sourceOrder != null &&
|
||||||
|
left.sourceOrder !== right.sourceOrder
|
||||||
|
) {
|
||||||
|
return left.sourceOrder - right.sourceOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.graphItem.id.localeCompare(right.graphItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMessageOwnerNodeId(args: {
|
function resolveMessageOwnerNodeId(args: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
|
||||||
leadId: string;
|
leadId: string;
|
||||||
leadName: string;
|
leadName: string;
|
||||||
ownerNodeIds: ReadonlySet<string>;
|
ownerNodeIds: ReadonlySet<string>;
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { message, teamName, leadId, leadName, ownerNodeIds } = args;
|
const { message, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args;
|
||||||
if (message.source === 'cross_team' || message.source === 'cross_team_sent') {
|
if (message.source === 'cross_team' || message.source === 'cross_team_sent') {
|
||||||
return leadId;
|
return leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName);
|
const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias);
|
||||||
const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId;
|
const toId = message.to
|
||||||
|
? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias)
|
||||||
|
: leadId;
|
||||||
|
|
||||||
if (toId !== leadId && ownerNodeIds.has(toId)) {
|
if (toId !== leadId && ownerNodeIds.has(toId)) {
|
||||||
return toId;
|
return toId;
|
||||||
|
|
@ -206,20 +243,20 @@ function resolveMessageOwnerNodeId(args: {
|
||||||
function resolveCommentOwnerNodeId(args: {
|
function resolveCommentOwnerNodeId(args: {
|
||||||
taskOwner: string | undefined;
|
taskOwner: string | undefined;
|
||||||
author: string;
|
author: string;
|
||||||
teamName: string;
|
|
||||||
leadId: string;
|
leadId: string;
|
||||||
leadName: string;
|
leadName: string;
|
||||||
ownerNodeIds: ReadonlySet<string>;
|
ownerNodeIds: ReadonlySet<string>;
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args;
|
const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args;
|
||||||
if (taskOwner) {
|
if (taskOwner) {
|
||||||
const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName);
|
const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias);
|
||||||
if (ownerNodeIds.has(ownerId)) {
|
if (ownerNodeIds.has(ownerId)) {
|
||||||
return ownerId;
|
return ownerId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorId = resolveParticipantId(author, teamName, leadId, leadName);
|
const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias);
|
||||||
if (ownerNodeIds.has(authorId)) {
|
if (ownerNodeIds.has(authorId)) {
|
||||||
return authorId;
|
return authorId;
|
||||||
}
|
}
|
||||||
|
|
@ -327,9 +364,9 @@ function getActivityMessageKey(message: InboxMessage): string {
|
||||||
|
|
||||||
function resolveParticipantId(
|
function resolveParticipantId(
|
||||||
name: string,
|
name: string,
|
||||||
teamName: string,
|
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName?: string
|
leadName: string | undefined,
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string {
|
): string {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (normalized === 'user' || normalized === 'team-lead') {
|
if (normalized === 'user' || normalized === 'team-lead') {
|
||||||
|
|
@ -338,7 +375,7 @@ function resolveParticipantId(
|
||||||
if (normalized === leadName?.trim().toLowerCase()) {
|
if (normalized === leadName?.trim().toLowerCase()) {
|
||||||
return leadId;
|
return leadId;
|
||||||
}
|
}
|
||||||
return `member:${teamName}:${name}`;
|
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParticipantLabel(name: string | undefined, leadName: string): string {
|
function buildParticipantLabel(name: string | undefined, leadName: string): string {
|
||||||
55
src/features/agent-graph/core/domain/graphOwnerIdentity.ts
Normal file
55
src/features/agent-graph/core/domain/graphOwnerIdentity.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { getStableTeamOwnerId, type StableTeamOwnerLike } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
|
export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||||
|
|
||||||
|
export function getGraphStableOwnerId(member: StableTeamOwnerLike): string {
|
||||||
|
return getStableTeamOwnerId(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraphMemberNodeId(teamName: string, stableOwnerId: string): string {
|
||||||
|
return `member:${teamName}:${stableOwnerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraphMemberNodeIdForMember(
|
||||||
|
teamName: string,
|
||||||
|
member: StableTeamOwnerLike
|
||||||
|
): string {
|
||||||
|
return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraphMemberNodeIdAliasMap(
|
||||||
|
teamName: string,
|
||||||
|
members: readonly StableTeamOwnerLike[]
|
||||||
|
): Map<string, string> {
|
||||||
|
const aliases = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const stableOwnerId = getGraphStableOwnerId(member).trim();
|
||||||
|
if (!stableOwnerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
aliases.set(stableOwnerId, buildGraphMemberNodeId(teamName, stableOwnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const memberName = member.name.trim();
|
||||||
|
if (!memberName || aliases.has(memberName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
aliases.set(memberName, buildGraphMemberNodeIdForMember(teamName, member));
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null {
|
||||||
|
const prefix = teamName ? `member:${teamName}:` : 'member:';
|
||||||
|
if (!nodeId.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (teamName) {
|
||||||
|
return nodeId.slice(prefix.length) || null;
|
||||||
|
}
|
||||||
|
const [, , ...rest] = nodeId.split(':');
|
||||||
|
return rest.length > 0 ? rest.join(':') : null;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
||||||
*
|
*
|
||||||
* This is the ONLY file in this feature that imports from @renderer/store.
|
* This adapter owns the graph projection from team runtime state into the
|
||||||
* If the project data model changes, ONLY this class needs updating.
|
* reusable package port model. Renderer hooks may still read store state, but
|
||||||
|
* projection rules stay here so the mapping logic has one main reason to change.
|
||||||
*
|
*
|
||||||
* Class-based with ES #private fields and DI-ready constructor.
|
* Class-based with ES #private fields and DI-ready constructor.
|
||||||
*/
|
*/
|
||||||
|
|
@ -22,24 +23,32 @@ import {
|
||||||
} from '@shared/utils/idleNotificationSemantics';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildInlineActivityEntries,
|
buildInlineActivityEntries,
|
||||||
getGraphLeadMemberName,
|
getGraphLeadMemberName,
|
||||||
} from '../utils/buildInlineActivityEntries';
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
|
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
|
||||||
|
import {
|
||||||
|
buildGraphMemberNodeIdAliasMap,
|
||||||
|
buildGraphMemberNodeIdForMember,
|
||||||
|
getGraphStableOwnerId,
|
||||||
|
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
|
} from '../../core/domain/graphOwnerIdentity';
|
||||||
import {
|
import {
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
isTaskInReviewCycle,
|
isTaskInReviewCycle,
|
||||||
resolveTaskReviewer,
|
resolveTaskReviewer,
|
||||||
} from '../utils/taskGraphSemantics';
|
} from '../../core/domain/taskGraphSemantics';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GraphActivityItem,
|
|
||||||
GraphDataPort,
|
GraphDataPort,
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
|
GraphLayoutPort,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
GraphNodeState,
|
GraphNodeState,
|
||||||
|
GraphOwnerSlotAssignment,
|
||||||
GraphParticle,
|
GraphParticle,
|
||||||
} from '@claude-teams/agent-graph';
|
} from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -49,6 +58,7 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
TeamData,
|
TeamData,
|
||||||
|
TeamProcess,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
import type { LeadContextUsage } from '@shared/types/team';
|
import type { LeadContextUsage } from '@shared/types/team';
|
||||||
|
|
@ -90,12 +100,23 @@ export class TeamGraphAdapter {
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>,
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
provisioningProgress?: TeamProvisioningProgress | null,
|
provisioningProgress?: TeamProvisioningProgress | null,
|
||||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot
|
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||||
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateStableOwnerIds = TeamGraphAdapter.#collectDuplicateStableOwnerIds(
|
||||||
|
teamData.members.filter((member) => !member.removedAt && !isLeadMember(member))
|
||||||
|
);
|
||||||
|
if (duplicateStableOwnerIds.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`[agent-graph] duplicate stable owner ids in team=${teamName}: ${duplicateStableOwnerIds.join(', ')}`
|
||||||
|
);
|
||||||
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset particle tracking when team changes
|
// Reset particle tracking when team changes
|
||||||
if (teamName !== this.#lastTeamName) {
|
if (teamName !== this.#lastTeamName) {
|
||||||
this.#seenMessageIds.clear();
|
this.#seenMessageIds.clear();
|
||||||
|
|
@ -115,6 +136,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
||||||
|
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
||||||
const provisioningPresentation = buildTeamProvisioningPresentation({
|
const provisioningPresentation = buildTeamProvisioningPresentation({
|
||||||
progress: provisioningProgress,
|
progress: provisioningProgress,
|
||||||
members: teamData.members,
|
members: teamData.members,
|
||||||
|
|
@ -144,6 +166,7 @@ export class TeamGraphAdapter {
|
||||||
leadId,
|
leadId,
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
|
memberNodeIdByAlias,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
@ -152,8 +175,8 @@ export class TeamGraphAdapter {
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
isLaunchSettling
|
isLaunchSettling
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
particles,
|
particles,
|
||||||
|
|
@ -162,9 +185,18 @@ export class TeamGraphAdapter {
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
edges
|
edges,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
|
this.#buildCommentParticles(
|
||||||
|
particles,
|
||||||
|
teamData,
|
||||||
|
teamName,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
edges,
|
||||||
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -173,6 +205,7 @@ export class TeamGraphAdapter {
|
||||||
teamName,
|
teamName,
|
||||||
teamColor: teamData.config.color ?? undefined,
|
teamColor: teamData.config.color ?? undefined,
|
||||||
isAlive: teamData.isAlive,
|
isAlive: teamData.isAlive,
|
||||||
|
layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,6 +228,97 @@ export class TeamGraphAdapter {
|
||||||
return getGraphLeadMemberName(data, teamName);
|
return getGraphLeadMemberName(data, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
|
||||||
|
return buildGraphMemberNodeIdAliasMap(
|
||||||
|
teamName,
|
||||||
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildLayoutPort(
|
||||||
|
data: TeamData,
|
||||||
|
teamName: string,
|
||||||
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||||
|
): GraphLayoutPort {
|
||||||
|
const ownerOrder: string[] = [];
|
||||||
|
const seenOwnerNodeIds = new Set<string>();
|
||||||
|
const visibleMembers = data.members.filter(
|
||||||
|
(member) => !member.removedAt && !isLeadMember(member)
|
||||||
|
);
|
||||||
|
const visibleMemberByStableOwnerId = new Map(
|
||||||
|
visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const)
|
||||||
|
);
|
||||||
|
const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(
|
||||||
|
data.members,
|
||||||
|
data.config.members ?? []
|
||||||
|
);
|
||||||
|
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
||||||
|
|
||||||
|
const pushMember = (member: TeamData['members'][number] | undefined): void => {
|
||||||
|
if (!member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeId = buildGraphMemberNodeIdForMember(teamName, member);
|
||||||
|
if (seenOwnerNodeIds.has(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenOwnerNodeIds.add(nodeId);
|
||||||
|
ownerOrder.push(nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||||
|
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||||
|
if (!visibleMember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!assignedStableOwnerIds.has(stableOwnerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushMember(visibleMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||||
|
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||||
|
if (!visibleMember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (assignedStableOwnerIds.has(stableOwnerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushMember(visibleMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAssignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||||
|
for (const member of visibleMembers) {
|
||||||
|
const stableOwnerId = getGraphStableOwnerId(member);
|
||||||
|
const assignment = slotAssignments?.[stableOwnerId];
|
||||||
|
if (!assignment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedAssignments[buildGraphMemberNodeIdForMember(teamName, member)] = assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
|
ownerOrder,
|
||||||
|
slotAssignments: normalizedAssignments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static #collectDuplicateStableOwnerIds(
|
||||||
|
members: readonly TeamData['members'][number][]
|
||||||
|
): string[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const member of members) {
|
||||||
|
const stableOwnerId = getGraphStableOwnerId(member);
|
||||||
|
counts.set(stableOwnerId, (counts.get(stableOwnerId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([stableOwnerId]) => stableOwnerId)
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean {
|
static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean {
|
||||||
if (!timestamp || cutoffMs == null) {
|
if (!timestamp || cutoffMs == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -287,6 +411,7 @@ export class TeamGraphAdapter {
|
||||||
leadMember?.effort
|
leadMember?.effort
|
||||||
),
|
),
|
||||||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||||
|
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||||
pendingApproval,
|
pendingApproval,
|
||||||
|
|
@ -324,6 +449,7 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
|
|
@ -336,7 +462,8 @@ export class TeamGraphAdapter {
|
||||||
if (member.removedAt) continue;
|
if (member.removedAt) continue;
|
||||||
if (isLeadMember(member)) continue;
|
if (isLeadMember(member)) continue;
|
||||||
|
|
||||||
const memberId = `member:${teamName}:${member.name}`;
|
const memberId =
|
||||||
|
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
||||||
const spawn = spawnStatuses?.[member.name];
|
const spawn = spawnStatuses?.[member.name];
|
||||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||||
activeTools?.[member.name],
|
activeTools?.[member.name],
|
||||||
|
|
@ -377,6 +504,7 @@ export class TeamGraphAdapter {
|
||||||
),
|
),
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||||
|
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
||||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: member.currentTaskId
|
||||||
|
|
@ -425,7 +553,8 @@ export class TeamGraphAdapter {
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>
|
commentReadState?: Record<string, unknown>,
|
||||||
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
|
|
@ -446,7 +575,7 @@ export class TeamGraphAdapter {
|
||||||
for (const task of data.tasks) {
|
for (const task of data.tasks) {
|
||||||
if (task.status === 'deleted') continue;
|
if (task.status === 'deleted') continue;
|
||||||
const taskId = `task:${teamName}:${task.id}`;
|
const taskId = `task:${teamName}:${task.id}`;
|
||||||
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
|
const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null;
|
||||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
|
@ -496,7 +625,7 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
|
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
|
||||||
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6);
|
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5);
|
||||||
const visibleTaskIds = new Set(
|
const visibleTaskIds = new Set(
|
||||||
visibleTaskNodes.flatMap((taskNode) =>
|
visibleTaskNodes.flatMap((taskNode) =>
|
||||||
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
|
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
|
||||||
|
|
@ -571,7 +700,10 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
for (const relatedId of task.related ?? []) {
|
for (const relatedId of task.related ?? []) {
|
||||||
if (!visibleTaskIds.has(relatedId)) continue;
|
if (!visibleTaskIds.has(relatedId)) continue;
|
||||||
const key = [task.id, relatedId].sort().join(':');
|
const key =
|
||||||
|
task.id.localeCompare(relatedId) <= 0
|
||||||
|
? `${task.id}:${relatedId}`
|
||||||
|
: `${relatedId}:${task.id}`;
|
||||||
if (this.#seenRelated.has(key)) continue;
|
if (this.#seenRelated.has(key)) continue;
|
||||||
this.#seenRelated.add(key);
|
this.#seenRelated.add(key);
|
||||||
edges.push({
|
edges.push({
|
||||||
|
|
@ -605,18 +737,21 @@ export class TeamGraphAdapter {
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string
|
teamName: string,
|
||||||
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
for (const proc of data.processes) {
|
for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses(
|
||||||
if (proc.stoppedAt) continue;
|
data.processes,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
)) {
|
||||||
const procId = `process:${teamName}:${proc.id}`;
|
const procId = `process:${teamName}:${proc.id}`;
|
||||||
const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null;
|
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: procId,
|
id: procId,
|
||||||
kind: 'process',
|
kind: 'process',
|
||||||
label: proc.label,
|
label: proc.label,
|
||||||
state: 'active',
|
state: 'active',
|
||||||
|
ownerId,
|
||||||
processUrl: proc.url ?? undefined,
|
processUrl: proc.url ?? undefined,
|
||||||
processRegisteredBy: proc.registeredBy ?? undefined,
|
processRegisteredBy: proc.registeredBy ?? undefined,
|
||||||
processCommand: proc.command ?? undefined,
|
processCommand: proc.command ?? undefined,
|
||||||
|
|
@ -635,6 +770,48 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #selectRelevantProcesses(
|
||||||
|
processes: readonly TeamProcess[],
|
||||||
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
|
): { process: TeamProcess; ownerId: string }[] {
|
||||||
|
const selectedByOwnerId = new Map<string, TeamProcess>();
|
||||||
|
|
||||||
|
for (const process of processes) {
|
||||||
|
const ownerId = process.registeredBy
|
||||||
|
? (memberNodeIdByAlias?.get(process.registeredBy) ?? null)
|
||||||
|
: null;
|
||||||
|
if (!ownerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = selectedByOwnerId.get(ownerId);
|
||||||
|
if (!existing || TeamGraphAdapter.#compareProcessPriority(process, existing) < 0) {
|
||||||
|
selectedByOwnerId.set(ownerId, process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(selectedByOwnerId.entries()).map(([ownerId, process]) => ({
|
||||||
|
process,
|
||||||
|
ownerId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static #compareProcessPriority(left: TeamProcess, right: TeamProcess): number {
|
||||||
|
const leftRank = left.stoppedAt ? 1 : 0;
|
||||||
|
const rightRank = right.stoppedAt ? 1 : 0;
|
||||||
|
if (leftRank !== rightRank) {
|
||||||
|
return leftRank - rightRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTimestamp = left.stoppedAt ?? left.registeredAt;
|
||||||
|
const rightTimestamp = right.stoppedAt ?? right.registeredAt;
|
||||||
|
if (leftTimestamp !== rightTimestamp) {
|
||||||
|
return rightTimestamp.localeCompare(leftTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
#attachActivityFeeds(
|
#attachActivityFeeds(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
|
|
@ -680,7 +857,8 @@ export class TeamGraphAdapter {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[]
|
edges: GraphEdge[],
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
const ordered = [...messages].reverse();
|
const ordered = [...messages].reverse();
|
||||||
|
|
||||||
|
|
@ -763,16 +941,22 @@ export class TeamGraphAdapter {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
|
const edgeId = TeamGraphAdapter.#resolveMessageEdge(
|
||||||
|
msg,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
edges,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
if (!edgeId) continue;
|
if (!edgeId) continue;
|
||||||
|
|
||||||
// Determine direction: messages FROM a teammate TO lead should reverse
|
// Determine direction: messages FROM a teammate TO lead should reverse
|
||||||
// (edges are always lead→member, but message goes member→lead)
|
// (edges are always lead→member, but message goes member→lead)
|
||||||
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
msg.from ?? '',
|
msg.from ?? '',
|
||||||
teamName,
|
|
||||||
leadId,
|
leadId,
|
||||||
leadName
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
const isFromTeammate = fromId !== leadId;
|
const isFromTeammate = fromId !== leadId;
|
||||||
|
|
||||||
|
|
@ -812,7 +996,8 @@ export class TeamGraphAdapter {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[]
|
edges: GraphEdge[],
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
// First call: record current comment counts without creating particles.
|
// First call: record current comment counts without creating particles.
|
||||||
// This prevents pre-existing comments from spawning particles when the graph opens.
|
// This prevents pre-existing comments from spawning particles when the graph opens.
|
||||||
|
|
@ -851,9 +1036,9 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
|
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
newComment.author,
|
newComment.author,
|
||||||
teamName,
|
|
||||||
leadId,
|
leadId,
|
||||||
leadName
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
const taskNodeId = `task:${teamName}:${task.id}`;
|
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||||
const authorEdge =
|
const authorEdge =
|
||||||
|
|
@ -985,16 +1170,26 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
static #resolveMessageEdge(
|
static #resolveMessageEdge(
|
||||||
msg: InboxMessage,
|
msg: InboxMessage,
|
||||||
teamName: string,
|
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[]
|
edges: GraphEdge[],
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string | null {
|
): string | null {
|
||||||
const { from, to } = msg;
|
const { from, to } = msg;
|
||||||
|
|
||||||
if (from && to) {
|
if (from && to) {
|
||||||
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
|
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName);
|
from,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
|
const toId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
|
to,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
||||||
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
||||||
|
|
@ -1003,7 +1198,12 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from && !to) {
|
if (from && !to) {
|
||||||
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
|
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
|
from,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
edges.find(
|
edges.find(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
|
@ -1018,14 +1218,14 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
static #resolveParticipantId(
|
static #resolveParticipantId(
|
||||||
name: string,
|
name: string,
|
||||||
teamName: string,
|
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName?: string
|
leadName: string | undefined,
|
||||||
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string {
|
): string {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
||||||
if (normalized === leadName?.trim().toLowerCase()) return leadId;
|
if (normalized === leadName?.trim().toLowerCase()) return leadId;
|
||||||
return `member:${teamName}:${name}`;
|
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import type { TeamData, TeamSummary } from '@shared/types/team';
|
||||||
|
|
||||||
|
export function useGraphActivityContext(teamName: string): {
|
||||||
|
teamData: TeamData | null;
|
||||||
|
teams: TeamSummary[];
|
||||||
|
} {
|
||||||
|
return useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
teamData: selectTeamDataForName(state, teamName),
|
||||||
|
teams: state.teams,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
getCurrentProvisioningProgressForTeam,
|
||||||
|
selectTeamDataForName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||||
|
return useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
||||||
|
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||||
|
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||||
|
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||||
|
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||||
|
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
|
const GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY = 'team-graph-sidebar-visible';
|
||||||
|
|
||||||
|
function readInitialVisibility(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphSidebarVisibility(): {
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
toggleSidebarVisible: () => void;
|
||||||
|
} {
|
||||||
|
const [sidebarEnabled, setSidebarEnabled] = useState<boolean>(readInitialVisibility);
|
||||||
|
const messagesPanelMode = useStore((state) => state.messagesPanelMode);
|
||||||
|
const setMessagesPanelMode = useStore((state) => state.setMessagesPanelMode);
|
||||||
|
const sidebarVisible = sidebarEnabled && messagesPanelMode === 'sidebar';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY, String(sidebarEnabled));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures and keep UI responsive.
|
||||||
|
}
|
||||||
|
}, [sidebarEnabled]);
|
||||||
|
|
||||||
|
const toggleSidebarVisible = useCallback(() => {
|
||||||
|
if (sidebarVisible) {
|
||||||
|
setSidebarEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSidebarEnabled(true);
|
||||||
|
if (messagesPanelMode !== 'sidebar') {
|
||||||
|
setMessagesPanelMode('sidebar');
|
||||||
|
}
|
||||||
|
}, [messagesPanelMode, setMessagesPanelMode, sidebarVisible]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarVisible,
|
||||||
|
toggleSidebarVisible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,19 @@
|
||||||
* Thin wrapper — instantiates the class adapter and calls adapt() with store data.
|
* Thin wrapper — instantiates the class adapter and calls adapt() with store data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useRef, useSyncExternalStore } from 'react';
|
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
|
@ -31,6 +33,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
toolHistory,
|
toolHistory,
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
|
slotAssignments,
|
||||||
|
graphLayoutSession,
|
||||||
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: selectTeamDataForName(s, teamName),
|
teamData: selectTeamDataForName(s, teamName),
|
||||||
|
|
@ -43,6 +48,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
|
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
|
||||||
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
||||||
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||||
|
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||||
|
graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||||
|
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -58,6 +66,53 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
|
|
||||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const effectiveSlotAssignments = useMemo(() => {
|
||||||
|
if (!teamData) {
|
||||||
|
return slotAssignments;
|
||||||
|
}
|
||||||
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||||
|
return slotAssignments;
|
||||||
|
}
|
||||||
|
if (graphLayoutSession?.mode === 'manual') {
|
||||||
|
return slotAssignments;
|
||||||
|
}
|
||||||
|
const defaultSeed = buildTeamGraphDefaultLayoutSeed(
|
||||||
|
teamData.members,
|
||||||
|
teamData.config.members ?? []
|
||||||
|
);
|
||||||
|
const defaultAssignments =
|
||||||
|
Object.keys(defaultSeed.assignments).length === 0 ? undefined : defaultSeed.assignments;
|
||||||
|
if (!slotAssignments) {
|
||||||
|
return defaultAssignments;
|
||||||
|
}
|
||||||
|
if (graphLayoutSession?.signature !== defaultSeed.signature) {
|
||||||
|
return defaultAssignments;
|
||||||
|
}
|
||||||
|
const visibleAssignmentKeys = defaultSeed.orderedVisibleOwnerIds.filter(
|
||||||
|
(stableOwnerId) => slotAssignments[stableOwnerId]
|
||||||
|
);
|
||||||
|
const hasExactVisibleDefaults =
|
||||||
|
visibleAssignmentKeys.length === Object.keys(defaultSeed.assignments).length &&
|
||||||
|
visibleAssignmentKeys.every((stableOwnerId) => {
|
||||||
|
const currentAssignment = slotAssignments[stableOwnerId];
|
||||||
|
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
||||||
|
return (
|
||||||
|
currentAssignment &&
|
||||||
|
defaultAssignment &&
|
||||||
|
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
|
||||||
|
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return hasExactVisibleDefaults ? slotAssignments : defaultAssignments;
|
||||||
|
}, [graphLayoutSession, slotAssignments, teamData]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!teamName || !teamData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []);
|
||||||
|
}, [ensureTeamGraphSlotAssignments, teamData, teamName]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
adapterRef.current.adapt(
|
adapterRef.current.adapt(
|
||||||
|
|
@ -72,7 +127,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
toolHistory,
|
toolHistory,
|
||||||
commentReadState,
|
commentReadState,
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot
|
memberSpawnSnapshot,
|
||||||
|
effectiveSlotAssignments
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
teamData,
|
teamData,
|
||||||
|
|
@ -87,6 +143,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
commentReadState,
|
commentReadState,
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
|
effectiveSlotAssignments,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||||
|
|
||||||
|
export function useTeamGraphSlotReset(teamName: string, enabled = true): void {
|
||||||
|
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||||
|
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled || !isTeamGraphSlotPersistenceDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||||
|
}, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
|
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||||
|
|
||||||
|
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
|
openTeamPage: () => void;
|
||||||
|
commitOwnerSlotDrop: (payload: {
|
||||||
|
nodeId: string;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
displacedNodeId?: string;
|
||||||
|
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||||
|
}) => void;
|
||||||
|
} {
|
||||||
|
const openTeamPage = useCallback(() => {
|
||||||
|
useStore.getState().openTeamTab(teamName);
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
const commitOwnerSlotDrop = useCallback(
|
||||||
|
(payload: {
|
||||||
|
nodeId: string;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
displacedNodeId?: string;
|
||||||
|
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||||
|
}) => {
|
||||||
|
const stableOwnerId = parseGraphMemberNodeId(payload.nodeId, teamName);
|
||||||
|
if (!stableOwnerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const displacedStableOwnerId = payload.displacedNodeId
|
||||||
|
? parseGraphMemberNodeId(payload.displacedNodeId, teamName)
|
||||||
|
: null;
|
||||||
|
const store = useStore.getState();
|
||||||
|
if (displacedStableOwnerId && payload.displacedAssignment) {
|
||||||
|
store.commitTeamGraphOwnerSlotDrop(
|
||||||
|
teamName,
|
||||||
|
stableOwnerId,
|
||||||
|
payload.assignment,
|
||||||
|
displacedStableOwnerId,
|
||||||
|
payload.displacedAssignment
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.setTeamGraphOwnerSlotAssignment(teamName, stableOwnerId, payload.assignment);
|
||||||
|
},
|
||||||
|
[teamName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openTeamPage,
|
||||||
|
commitOwnerSlotDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/features/agent-graph/renderer/index.ts
Normal file
13
src/features/agent-graph/renderer/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* agent-graph renderer feature - public API.
|
||||||
|
*
|
||||||
|
* Consumers outside the feature should import from here instead of reaching
|
||||||
|
* into ui/, hooks/, or core/ directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
||||||
|
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
||||||
|
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
||||||
|
export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay';
|
||||||
|
export { TeamGraphOverlay } from './ui/TeamGraphOverlay';
|
||||||
|
export { TeamGraphTab } from './ui/TeamGraphTab';
|
||||||
532
src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
Normal file
532
src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
||||||
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
|
import {
|
||||||
|
buildMessageContext,
|
||||||
|
resolveMessageRenderProps,
|
||||||
|
} from '@renderer/components/team/activity/activityMessageContext';
|
||||||
|
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||||
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildInlineActivityEntries,
|
||||||
|
getGraphLeadMemberName,
|
||||||
|
type InlineActivityEntry,
|
||||||
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
|
import type {
|
||||||
|
MemberActivityFilter,
|
||||||
|
MemberDetailTab,
|
||||||
|
} from '@renderer/components/team/members/memberDetailTypes';
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types/team';
|
||||||
|
|
||||||
|
const ACTIVITY_SHELL_HEIGHT =
|
||||||
|
ACTIVITY_LANE.headerHeight +
|
||||||
|
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||||
|
ACTIVITY_LANE.overflowHeight;
|
||||||
|
|
||||||
|
interface GraphActivityHudProps {
|
||||||
|
teamName: string;
|
||||||
|
nodes: GraphNode[];
|
||||||
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
|
getCameraZoom?: () => number;
|
||||||
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
getViewportSize?: () => { width: number; height: number };
|
||||||
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
|
onOpenMemberProfile?: (
|
||||||
|
memberName: string,
|
||||||
|
options?: {
|
||||||
|
initialTab?: MemberDetailTab;
|
||||||
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphActivityHud = ({
|
||||||
|
teamName,
|
||||||
|
nodes,
|
||||||
|
getActivityWorldRect = () => null,
|
||||||
|
getCameraZoom = () => 1,
|
||||||
|
worldToScreen,
|
||||||
|
getNodeWorldPosition = () => null,
|
||||||
|
getViewportSize,
|
||||||
|
focusNodeIds,
|
||||||
|
enabled = true,
|
||||||
|
onOpenTaskDetail,
|
||||||
|
onOpenMemberProfile,
|
||||||
|
}: GraphActivityHudProps): React.JSX.Element | null => {
|
||||||
|
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||||
|
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
|
||||||
|
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||||
|
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||||
|
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||||
|
|
||||||
|
const ownerNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes.filter(
|
||||||
|
(node): node is GraphNode & { kind: 'lead' | 'member' } =>
|
||||||
|
node.kind === 'lead' || node.kind === 'member'
|
||||||
|
),
|
||||||
|
[nodes]
|
||||||
|
);
|
||||||
|
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
||||||
|
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
|
||||||
|
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
||||||
|
const entryMapByOwnerNodeId = useMemo(() => {
|
||||||
|
if (!teamData) {
|
||||||
|
return new Map<string, InlineActivityEntry[]>();
|
||||||
|
}
|
||||||
|
return buildInlineActivityEntries({
|
||||||
|
data: teamData,
|
||||||
|
teamName,
|
||||||
|
leadId: leadNodeId,
|
||||||
|
leadName,
|
||||||
|
ownerNodeIds,
|
||||||
|
});
|
||||||
|
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
||||||
|
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||||
|
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||||
|
const { readSet } = useTeamMessagesRead(teamName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandedItem(null);
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
const visibleLanes = useMemo(() => {
|
||||||
|
return ownerNodes
|
||||||
|
.map((node) => {
|
||||||
|
const graphItems = node.activityItems ?? [];
|
||||||
|
const overflowCount = node.activityOverflowCount ?? 0;
|
||||||
|
const visibleCount = Math.max(0, graphItems.length - overflowCount);
|
||||||
|
const visibleGraphItems = graphItems.slice(0, visibleCount);
|
||||||
|
const entriesById = new Map(
|
||||||
|
(entryMapByOwnerNodeId.get(node.id) ?? []).map(
|
||||||
|
(entry) => [entry.graphItem.id, entry] as const
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const entries = visibleGraphItems
|
||||||
|
.map((item) => entriesById.get(item.id))
|
||||||
|
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
||||||
|
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
entries,
|
||||||
|
overflowCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0
|
||||||
|
);
|
||||||
|
}, [entryMapByOwnerNodeId, ownerNodes]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled || visibleLanes.length === 0) {
|
||||||
|
for (const shell of shellRefs.current.values()) {
|
||||||
|
if (shell) {
|
||||||
|
shell.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const connector of connectorRefs.current.values()) {
|
||||||
|
if (connector) {
|
||||||
|
connector.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameId = 0;
|
||||||
|
const updatePositions = (): void => {
|
||||||
|
const worldLayer = worldLayerRef.current;
|
||||||
|
if (worldLayer && worldToScreen) {
|
||||||
|
const origin = worldToScreen(0, 0);
|
||||||
|
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||||
|
worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurableLanes: {
|
||||||
|
lane: (typeof visibleLanes)[number];
|
||||||
|
shell: HTMLDivElement;
|
||||||
|
connector: SVGSVGElement | null;
|
||||||
|
connectorPath: SVGPathElement | null;
|
||||||
|
laneRect: NonNullable<ReturnType<typeof getActivityWorldRect>>;
|
||||||
|
nodeWorld: { x: number; y: number };
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const lane of visibleLanes) {
|
||||||
|
const shell = shellRefs.current.get(lane.node.id);
|
||||||
|
if (!shell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const connector = connectorRefs.current.get(lane.node.id) ?? null;
|
||||||
|
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
|
||||||
|
|
||||||
|
const laneRect = getActivityWorldRect(lane.node.id);
|
||||||
|
const nodeWorld = getNodeWorldPosition(lane.node.id);
|
||||||
|
if (!laneRect || !nodeWorld || !worldToScreen) {
|
||||||
|
shell.style.opacity = '0';
|
||||||
|
if (connector) {
|
||||||
|
connector.style.opacity = '0';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||||
|
const screenTopLeft = worldToScreen(laneRect.left, laneRect.top);
|
||||||
|
const widthScreen = Math.max(1, laneRect.width * zoom);
|
||||||
|
const heightScreen = Math.max(1, laneRect.height * zoom);
|
||||||
|
const viewport = getViewportSize?.();
|
||||||
|
const laneVisible =
|
||||||
|
!viewport ||
|
||||||
|
(screenTopLeft.x + widthScreen > -80 &&
|
||||||
|
screenTopLeft.x < viewport.width + 80 &&
|
||||||
|
screenTopLeft.y + heightScreen > -80 &&
|
||||||
|
screenTopLeft.y < viewport.height + 80);
|
||||||
|
if (!laneVisible) {
|
||||||
|
shell.style.opacity = '0';
|
||||||
|
if (connector) {
|
||||||
|
connector.style.opacity = '0';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
measurableLanes.push({
|
||||||
|
lane,
|
||||||
|
shell,
|
||||||
|
connector,
|
||||||
|
connectorPath,
|
||||||
|
laneRect,
|
||||||
|
nodeWorld,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of measurableLanes) {
|
||||||
|
const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry;
|
||||||
|
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
|
||||||
|
|
||||||
|
shell.style.opacity = String(baseOpacity);
|
||||||
|
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||||
|
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||||
|
shell.style.transform = '';
|
||||||
|
|
||||||
|
if (connector && connectorPath) {
|
||||||
|
const endX = laneRect.left + laneRect.width / 2;
|
||||||
|
const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10;
|
||||||
|
const startX = nodeWorld.x;
|
||||||
|
const startY = nodeWorld.y - 18;
|
||||||
|
const minX = Math.min(startX, endX);
|
||||||
|
const minY = Math.min(startY, endY);
|
||||||
|
const connectorWidth = Math.max(1, Math.abs(endX - startX));
|
||||||
|
const connectorHeight = Math.max(1, Math.abs(endY - startY));
|
||||||
|
const localStartX = startX - minX;
|
||||||
|
const localStartY = startY - minY;
|
||||||
|
const localEndX = endX - minX;
|
||||||
|
const localEndY = endY - minY;
|
||||||
|
const dx = localEndX - localStartX;
|
||||||
|
const curve = Math.max(28, Math.abs(dx) * 0.35);
|
||||||
|
const c1x = localStartX + Math.sign(dx || 1) * curve;
|
||||||
|
const c1y = localStartY;
|
||||||
|
const c2x = localEndX - Math.sign(dx || 1) * curve;
|
||||||
|
const c2y = localEndY;
|
||||||
|
|
||||||
|
connector.style.opacity = String(baseOpacity);
|
||||||
|
connector.style.left = `${Math.round(minX)}px`;
|
||||||
|
connector.style.top = `${Math.round(minY)}px`;
|
||||||
|
connector.setAttribute('width', String(Math.ceil(connectorWidth)));
|
||||||
|
connector.setAttribute('height', String(Math.ceil(connectorHeight)));
|
||||||
|
connector.setAttribute(
|
||||||
|
'viewBox',
|
||||||
|
`0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}`
|
||||||
|
);
|
||||||
|
connectorPath.setAttribute(
|
||||||
|
'd',
|
||||||
|
`M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(updatePositions);
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePositions();
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
focusNodeIds,
|
||||||
|
getActivityWorldRect,
|
||||||
|
getCameraZoom,
|
||||||
|
getNodeWorldPosition,
|
||||||
|
getViewportSize,
|
||||||
|
worldToScreen,
|
||||||
|
visibleLanes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const expandedItemsByKey = useMemo(() => {
|
||||||
|
const items = new Map<string, TimelineItem>();
|
||||||
|
for (const lane of visibleLanes) {
|
||||||
|
for (const entry of lane.entries) {
|
||||||
|
const key = toMessageKey(entry.message);
|
||||||
|
items.set(key, { type: 'message', message: entry.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [visibleLanes]);
|
||||||
|
|
||||||
|
const handleExpandItem = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const next = expandedItemsByKey.get(key);
|
||||||
|
if (next) {
|
||||||
|
setExpandedItem(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedItemsByKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMessageClick = useCallback((item: TimelineItem) => {
|
||||||
|
setExpandedItem(item);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMemberNameClick = useCallback(
|
||||||
|
(memberName: string) => {
|
||||||
|
onOpenMemberProfile?.(memberName);
|
||||||
|
},
|
||||||
|
[onOpenMemberProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMemberClick = useCallback(
|
||||||
|
(member: ResolvedTeamMember) => {
|
||||||
|
onOpenMemberProfile?.(member.name);
|
||||||
|
},
|
||||||
|
[onOpenMemberProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenOwnerActivity = useCallback(
|
||||||
|
(node: GraphNode & { kind: 'lead' | 'member' }) => {
|
||||||
|
if (node.domainRef.kind !== 'lead' && node.domainRef.kind !== 'member') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenMemberProfile?.(node.domainRef.memberName, {
|
||||||
|
initialTab: 'activity',
|
||||||
|
initialActivityFilter: 'all',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onOpenMemberProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => {
|
||||||
|
const graphRoot = shell.closest('.team-graph-view');
|
||||||
|
const canvas = graphRoot?.querySelector('canvas');
|
||||||
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
canvas.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaX: event.deltaX,
|
||||||
|
deltaY: event.deltaY,
|
||||||
|
deltaMode: event.deltaMode,
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = [];
|
||||||
|
|
||||||
|
for (const lane of visibleLanes) {
|
||||||
|
const shell = shellRefs.current.get(lane.node.id);
|
||||||
|
if (!shell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell);
|
||||||
|
shell.addEventListener('wheel', handler, { passive: false });
|
||||||
|
listeners.push({ shell, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const { shell, handler } of listeners) {
|
||||||
|
shell.removeEventListener('wheel', handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||||
|
|
||||||
|
if (!enabled || !teamData || visibleLanes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={worldLayerRef}
|
||||||
|
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left select-none"
|
||||||
|
>
|
||||||
|
{visibleLanes.map((lane) => (
|
||||||
|
<div key={lane.node.id}>
|
||||||
|
{(() => {
|
||||||
|
const laneRect = getActivityWorldRect(lane.node.id);
|
||||||
|
const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width;
|
||||||
|
const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
ref={(element) => {
|
||||||
|
connectorRefs.current.set(lane.node.id, element);
|
||||||
|
}}
|
||||||
|
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
ref={(element) => {
|
||||||
|
connectorPathRefs.current.set(lane.node.id, element);
|
||||||
|
}}
|
||||||
|
d=""
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(148, 163, 184, 0.3)"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
ref={(element) => {
|
||||||
|
shellRefs.current.set(lane.node.id, element);
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||||
|
style={{
|
||||||
|
width: `${laneWidth}px`,
|
||||||
|
maxWidth: `${laneWidth}px`,
|
||||||
|
height: `${laneHeight}px`,
|
||||||
|
}}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
|
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||||
|
Activity
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
|
||||||
|
<div className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-[11px] text-slate-400/60">
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{lane.entries.map((entry, index) => {
|
||||||
|
const messageKey = toMessageKey(entry.message);
|
||||||
|
const renderProps = resolveMessageRenderProps(
|
||||||
|
entry.message,
|
||||||
|
messageContext
|
||||||
|
);
|
||||||
|
const timelineItem: TimelineItem = {
|
||||||
|
type: 'message',
|
||||||
|
message: entry.message,
|
||||||
|
};
|
||||||
|
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.graphItem.id}
|
||||||
|
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => handleMessageClick(timelineItem)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMessageClick(timelineItem);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityItem
|
||||||
|
message={entry.message}
|
||||||
|
teamName={teamName}
|
||||||
|
compactHeader
|
||||||
|
collapseMode="managed"
|
||||||
|
isCollapsed
|
||||||
|
canToggleCollapse={false}
|
||||||
|
isUnread={isUnread}
|
||||||
|
expandItemKey={messageKey}
|
||||||
|
onExpand={handleExpandItem}
|
||||||
|
memberRole={renderProps.memberRole}
|
||||||
|
memberColor={renderProps.memberColor}
|
||||||
|
recipientColor={renderProps.recipientColor}
|
||||||
|
memberColorMap={messageContext.colorMap}
|
||||||
|
localMemberNames={messageContext.localMemberNames}
|
||||||
|
onMemberNameClick={handleMemberNameClick}
|
||||||
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
|
zebraShade={index % 2 === 1}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{lane.overflowCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||||
|
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||||
|
>
|
||||||
|
+{lane.overflowCount} more
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageExpandDialog
|
||||||
|
expandedItem={expandedItem}
|
||||||
|
open={expandedItem !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setExpandedItem(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
teamName={teamName}
|
||||||
|
members={teamData.members}
|
||||||
|
onMemberClick={handleMemberClick}
|
||||||
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,8 +2,8 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -63,7 +63,7 @@ export const GraphBlockingEdgePopover = ({
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
onOpenTaskDetail,
|
onOpenTaskDetail,
|
||||||
}: GraphBlockingEdgePopoverProps): React.JSX.Element => {
|
}: GraphBlockingEdgePopoverProps): React.JSX.Element => {
|
||||||
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
|
const { teamData } = useGraphActivityContext(teamName);
|
||||||
const tasksById = useMemo(
|
const tasksById = useMemo(
|
||||||
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)),
|
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)),
|
||||||
[teamData?.tasks]
|
[teamData?.tasks]
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* GraphNodePopover — renders popover for graph nodes using project UI components.
|
* GraphNodePopover — renders popover for graph nodes using project UI components.
|
||||||
* Lives in features/ (not in package) so it CAN import from @renderer/.
|
* This stays in the renderer slice instead of the reusable package because it
|
||||||
* Reuses agentAvatarUrl, status helpers, and UI primitives from the project.
|
* composes project-specific UI, selectors, and presentation helpers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import {
|
|
||||||
getCurrentProvisioningProgressForTeam,
|
|
||||||
selectTeamDataForName,
|
|
||||||
} from '@renderer/store/slices/teamSlice';
|
|
||||||
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
|
|
||||||
import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
|
import { isTaskInReviewCycle, resolveTaskReviewer } from '../../core/domain/taskGraphSemantics';
|
||||||
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverContext';
|
||||||
|
|
||||||
import { GraphTaskCard } from './GraphTaskCard';
|
import { GraphTaskCard } from './GraphTaskCard';
|
||||||
|
|
||||||
|
|
@ -40,11 +36,22 @@ function formatToolPreview(preview: string | undefined): string | undefined {
|
||||||
// If it looks like raw JSON object, try to extract a readable field
|
// If it looks like raw JSON object, try to extract a readable field
|
||||||
if (preview.startsWith('{') || preview.startsWith('[')) {
|
if (preview.startsWith('{') || preview.startsWith('[')) {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
|
const parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
|
||||||
// Common readable fields
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
return (
|
const previewRecord = parsed as Record<string, unknown>;
|
||||||
obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined
|
const candidates = [
|
||||||
);
|
previewRecord.subject,
|
||||||
|
previewRecord.name,
|
||||||
|
previewRecord.label,
|
||||||
|
previewRecord.file_path,
|
||||||
|
previewRecord.path,
|
||||||
|
previewRecord.query,
|
||||||
|
];
|
||||||
|
const firstText = candidates.find((value) => typeof value === 'string');
|
||||||
|
if (typeof firstText === 'string') {
|
||||||
|
return firstText;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Truncated JSON — extract first quoted value
|
// Truncated JSON — extract first quoted value
|
||||||
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
|
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
|
||||||
|
|
@ -202,7 +209,7 @@ const OverflowPopoverContent = ({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
|
const { teamData } = useGraphActivityContext(teamName);
|
||||||
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
|
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
|
||||||
const hiddenTasks = (node.overflowTaskIds ?? [])
|
const hiddenTasks = (node.overflowTaskIds ?? [])
|
||||||
.map((taskId) => tasksById.get(taskId) ?? null)
|
.map((taskId) => tasksById.get(taskId) ?? null)
|
||||||
|
|
@ -286,16 +293,7 @@ const MemberPopoverContent = ({
|
||||||
: '';
|
: '';
|
||||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
||||||
useStore(
|
useGraphMemberPopoverContext(teamName, memberName);
|
||||||
useShallow((state) => ({
|
|
||||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
|
||||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
|
||||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
|
||||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
|
||||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
|
||||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
||||||
const provisioningPresentation =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
|
|
@ -322,11 +320,17 @@ const MemberPopoverContent = ({
|
||||||
: null;
|
: null;
|
||||||
const fallbackSpawnStatusLabel =
|
const fallbackSpawnStatusLabel =
|
||||||
node.spawnStatus && node.spawnStatus !== 'online'
|
node.spawnStatus && node.spawnStatus !== 'online'
|
||||||
? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning'
|
? node.spawnStatus === 'waiting'
|
||||||
? 'starting'
|
? 'waiting to start'
|
||||||
: node.spawnStatus
|
: node.spawnStatus === 'spawning'
|
||||||
|
? 'starting'
|
||||||
|
: node.spawnStatus === 'error'
|
||||||
|
? 'failed'
|
||||||
|
: node.spawnStatus
|
||||||
: null;
|
: null;
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
|
launchPresentation?.launchStatusLabel ??
|
||||||
|
node.launchStatusLabel ??
|
||||||
launchPresentation?.presenceLabel ??
|
launchPresentation?.presenceLabel ??
|
||||||
fallbackSpawnStatusLabel ??
|
fallbackSpawnStatusLabel ??
|
||||||
(node.state === 'active'
|
(node.state === 'active'
|
||||||
|
|
@ -421,7 +425,7 @@ const MemberPopoverContent = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}
|
{/* Context usage stays hidden for now because LeadContextUsage.percent is unreliable. */}
|
||||||
|
|
||||||
{/* Current task indicator — reuses same pattern as MemberCard */}
|
{/* Current task indicator — reuses same pattern as MemberCard */}
|
||||||
{node.currentTaskId && node.currentTaskSubject && (
|
{node.currentTaskId && node.currentTaskSubject && (
|
||||||
115
src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
Normal file
115
src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
||||||
|
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||||
|
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
|
||||||
|
import { useTeamProvisioningPresentation } from '@renderer/components/team/useTeamProvisioningPresentation';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
|
||||||
|
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ key: step.key, label: step.label }));
|
||||||
|
const HUD_STEPPER_STYLE: CSSProperties = {
|
||||||
|
['--stepper-done' as string]: '#22c55e',
|
||||||
|
['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)',
|
||||||
|
['--stepper-current' as string]: '#22c55e',
|
||||||
|
['--stepper-current-ring' as string]: 'rgba(34, 197, 94, 0.18)',
|
||||||
|
['--stepper-pending' as string]: 'rgba(148, 163, 184, 0.08)',
|
||||||
|
['--stepper-pending-text' as string]: '#cbd5e1',
|
||||||
|
['--stepper-pending-border' as string]: 'rgba(148, 163, 184, 0.2)',
|
||||||
|
['--stepper-line' as string]: 'rgba(148, 163, 184, 0.14)',
|
||||||
|
['--stepper-line-done' as string]: '#22c55e',
|
||||||
|
['--stepper-label' as string]: '#94a3b8',
|
||||||
|
['--stepper-label-active' as string]: '#e2e8f0',
|
||||||
|
['--stepper-error' as string]: '#ef4444',
|
||||||
|
['--stepper-error-glow' as string]: 'rgba(239, 68, 68, 0.22)',
|
||||||
|
['--stepper-label-error' as string]: '#fca5a5',
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean {
|
||||||
|
return presentation != null && (presentation.isActive || presentation.isFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphProvisioningHudProps {
|
||||||
|
teamName: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphProvisioningHud = ({
|
||||||
|
teamName,
|
||||||
|
enabled = true,
|
||||||
|
}: GraphProvisioningHudProps): React.JSX.Element | null => {
|
||||||
|
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
||||||
|
const lastActiveStepRef = useRef(-1);
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
|
||||||
|
const errorStepIndex = presentation?.isFailed
|
||||||
|
? lastActiveStepRef.current >= 0
|
||||||
|
? lastActiveStepRef.current
|
||||||
|
: 0
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDetailsOpen(false);
|
||||||
|
lastActiveStepRef.current = -1;
|
||||||
|
}, [runInstanceKey, teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
||||||
|
lastActiveStepRef.current = presentation.currentStepIndex;
|
||||||
|
}
|
||||||
|
}, [presentation]);
|
||||||
|
|
||||||
|
const ariaLabel = useMemo(() => {
|
||||||
|
const parts = [presentation?.compactTitle, presentation?.compactDetail].filter(Boolean);
|
||||||
|
return parts.join(' - ') || 'Open launch details';
|
||||||
|
}, [presentation?.compactDetail, presentation?.compactTitle]);
|
||||||
|
|
||||||
|
if (!shouldRender || !presentation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-visible:ring-white/18 w-full touch-none select-none rounded-xl bg-transparent px-1 py-0.5 text-left text-slate-100 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-1"
|
||||||
|
onClick={() => setDetailsOpen(true)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="px-1 py-0.5" style={HUD_STEPPER_STYLE}>
|
||||||
|
<StepProgressBar
|
||||||
|
steps={MINI_STEPS}
|
||||||
|
currentIndex={presentation.currentStepIndex}
|
||||||
|
errorIndex={errorStepIndex}
|
||||||
|
className="w-full origin-top scale-[0.88]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{ariaLabel}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
|
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>Launch details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Detailed team launch progress, live output and CLI logs.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[85vh] overflow-y-auto p-4">
|
||||||
|
<TeamProvisioningPanel teamName={teamName} surface="flat" defaultLogsOpen />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* GraphTaskCard — wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects.
|
* GraphTaskCard — wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects.
|
||||||
* Lives in features/ so it CAN import from @renderer/.
|
* This is a renderer integration component, so it is allowed to compose
|
||||||
|
* project UI primitives and store-backed selectors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
|
|
||||||
import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics';
|
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
||||||
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
import type { KanbanColumnId, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -83,14 +82,10 @@ export const GraphTaskCard = ({
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: GraphTaskCardProps): React.JSX.Element => {
|
}: GraphTaskCardProps): React.JSX.Element => {
|
||||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||||
|
const { teamData } = useGraphActivityContext(teamName);
|
||||||
const { task, tasks, members } = useStore(
|
const tasks = teamData?.tasks ?? [];
|
||||||
useShallow((s) => ({
|
const members = teamData?.members ?? [];
|
||||||
tasks: selectTeamDataForName(s, teamName)?.tasks ?? [],
|
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||||
members: selectTeamDataForName(s, teamName)?.members ?? [],
|
|
||||||
task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const taskMap = useMemo(() => {
|
const taskMap = useMemo(() => {
|
||||||
const map = new Map<string, TeamTask>();
|
const map = new Map<string, TeamTask>();
|
||||||
|
|
@ -119,8 +114,8 @@ export const GraphTaskCard = ({
|
||||||
const columnId = resolveColumn(task);
|
const columnId = resolveColumn(task);
|
||||||
const taskWithKanban = task;
|
const taskWithKanban = task;
|
||||||
|
|
||||||
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
|
const closeAct = (fn?: (id: string) => void) => (nextTaskId: string) => {
|
||||||
fn?.(taskId);
|
fn?.(nextTaskId);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -7,15 +7,17 @@ import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { GraphView } from '@claude-teams/agent-graph';
|
import { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
|
|
||||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
|
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||||
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
import { GraphActivityHud } from './GraphActivityHud';
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
|
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -27,6 +29,8 @@ export interface TeamGraphOverlayProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPinAsTab?: () => void;
|
onPinAsTab?: () => void;
|
||||||
|
sidebarVisible?: boolean;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
onSendMessage?: (memberName: string) => void;
|
onSendMessage?: (memberName: string) => void;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
onOpenMemberProfile?: (
|
onOpenMemberProfile?: (
|
||||||
|
|
@ -42,16 +46,21 @@ export const TeamGraphOverlay = ({
|
||||||
teamName,
|
teamName,
|
||||||
onClose,
|
onClose,
|
||||||
onPinAsTab,
|
onPinAsTab,
|
||||||
|
sidebarVisible,
|
||||||
|
onToggleSidebar,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onOpenTaskDetail,
|
onOpenTaskDetail,
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
|
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
|
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||||
|
useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
const leadNodeId = useMemo(
|
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||||
[graphData.nodes]
|
|
||||||
);
|
useTeamGraphSlotReset(teamName);
|
||||||
|
|
||||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
|
|
@ -73,9 +82,9 @@ export const TeamGraphOverlay = ({
|
||||||
[dispatchTaskAction]
|
[dispatchTaskAction]
|
||||||
);
|
);
|
||||||
const openTeamPage = useCallback(() => {
|
const openTeamPage = useCallback(() => {
|
||||||
useStore.getState().openTeamTab(teamName);
|
openTeamTab();
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose, teamName]);
|
}, [onClose, openTeamTab]);
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
@ -104,38 +113,56 @@ export const TeamGraphOverlay = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex overflow-hidden" style={{ background: '#050510' }}>
|
<div className="fixed inset-0 z-50 flex overflow-hidden" style={{ background: '#050510' }}>
|
||||||
<TeamSidebarHost teamName={teamName} surface="graph-overlay" isActive isFocused />
|
{effectiveSidebarVisible ? (
|
||||||
|
<TeamSidebarHost teamName={teamName} surface="graph-overlay" isActive isFocused />
|
||||||
|
) : null}
|
||||||
<GraphView
|
<GraphView
|
||||||
data={graphData}
|
data={graphData}
|
||||||
events={events}
|
events={events}
|
||||||
|
isSurfaceActive
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onRequestPinAsTab={onPinAsTab}
|
onRequestPinAsTab={onPinAsTab}
|
||||||
onOpenTeamPage={openTeamPage}
|
onOpenTeamPage={openTeamPage}
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
|
onToggleSidebar={handleToggleSidebar}
|
||||||
|
isSidebarVisible={effectiveSidebarVisible}
|
||||||
|
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
|
||||||
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
className="team-graph-view min-w-0 flex-1"
|
className="team-graph-view min-w-0 flex-1"
|
||||||
renderHud={({
|
renderHud={(hudProps) => {
|
||||||
getLaunchAnchorScreenPlacement,
|
const extraHudProps = hudProps as typeof hudProps & {
|
||||||
getActivityAnchorScreenPlacement,
|
getViewportSize?: () => { width: number; height: number };
|
||||||
getNodeScreenPosition,
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
focusNodeIds,
|
left: number;
|
||||||
}) => (
|
top: number;
|
||||||
<>
|
right: number;
|
||||||
<GraphActivityHud
|
bottom: number;
|
||||||
teamName={teamName}
|
width: number;
|
||||||
nodes={graphData.nodes}
|
height: number;
|
||||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
} | null;
|
||||||
getNodeScreenPosition={getNodeScreenPosition}
|
getCameraZoom?: () => number;
|
||||||
focusNodeIds={focusNodeIds}
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
onOpenTaskDetail={onOpenTaskDetail}
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
onOpenMemberProfile={onOpenMemberProfile}
|
};
|
||||||
/>
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
return (
|
||||||
leadNodeId={leadNodeId}
|
<>
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
<GraphActivityHud
|
||||||
/>
|
teamName={teamName}
|
||||||
</>
|
nodes={graphData.nodes}
|
||||||
)}
|
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||||
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
|
getViewportSize={getViewportSize}
|
||||||
|
focusNodeIds={focusNodeIds}
|
||||||
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
|
onOpenMemberProfile={onOpenMemberProfile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
|
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
|
||||||
<GraphBlockingEdgePopover
|
<GraphBlockingEdgePopover
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -7,17 +7,19 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { GraphView } from '@claude-teams/agent-graph';
|
import { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
|
|
||||||
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
|
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
|
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||||
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
import { GraphActivityHud } from './GraphActivityHud';
|
import { GraphActivityHud } from './GraphActivityHud';
|
||||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||||
import { GraphNodePopover } from './GraphNodePopover';
|
import { GraphNodePopover } from './GraphNodePopover';
|
||||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||||
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
|
|
||||||
|
|
||||||
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
MemberActivityFilter,
|
MemberActivityFilter,
|
||||||
MemberDetailTab,
|
MemberDetailTab,
|
||||||
|
|
@ -44,13 +46,13 @@ export const TeamGraphTab = ({
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const leadNodeId = useMemo(
|
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
|
||||||
[graphData.nodes]
|
|
||||||
);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
|
|
||||||
|
useTeamGraphSlotReset(teamName, isActive);
|
||||||
|
|
||||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||||
const dispatchOpenTask = useCallback(
|
const dispatchOpenTask = useCallback(
|
||||||
(taskId: string) =>
|
(taskId: string) =>
|
||||||
|
|
@ -73,9 +75,6 @@ export const TeamGraphTab = ({
|
||||||
),
|
),
|
||||||
[teamName]
|
[teamName]
|
||||||
);
|
);
|
||||||
const openTeamPage = useCallback(() => {
|
|
||||||
useStore.getState().openTeamTab(teamName);
|
|
||||||
}, [teamName]);
|
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
@ -130,46 +129,65 @@ export const TeamGraphTab = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||||
<TeamSidebarHost
|
{sidebarVisible ? (
|
||||||
teamName={teamName}
|
<TeamSidebarHost
|
||||||
surface="graph-tab"
|
teamName={teamName}
|
||||||
isActive={isActive}
|
surface="graph-tab"
|
||||||
isFocused={isPaneFocused}
|
isActive={isActive}
|
||||||
/>
|
isFocused={isPaneFocused}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<GraphView
|
<GraphView
|
||||||
data={graphData}
|
data={graphData}
|
||||||
events={events}
|
events={events}
|
||||||
className="team-graph-view size-full"
|
className="team-graph-view size-full"
|
||||||
suspendAnimation={!isActive}
|
suspendAnimation={!isActive}
|
||||||
|
isSurfaceActive={isActive}
|
||||||
onRequestFullscreen={() => setFullscreen(true)}
|
onRequestFullscreen={() => setFullscreen(true)}
|
||||||
onOpenTeamPage={openTeamPage}
|
onOpenTeamPage={openTeamPage}
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
renderHud={({
|
onToggleSidebar={toggleSidebarVisible}
|
||||||
getLaunchAnchorScreenPlacement,
|
isSidebarVisible={sidebarVisible}
|
||||||
getActivityAnchorScreenPlacement,
|
renderTopToolbarContent={() => (
|
||||||
getNodeScreenPosition,
|
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
|
||||||
focusNodeIds,
|
|
||||||
}) => (
|
|
||||||
<>
|
|
||||||
<GraphActivityHud
|
|
||||||
teamName={teamName}
|
|
||||||
nodes={graphData.nodes}
|
|
||||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
|
||||||
getNodeScreenPosition={getNodeScreenPosition}
|
|
||||||
focusNodeIds={focusNodeIds}
|
|
||||||
enabled={isActive}
|
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
|
||||||
/>
|
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
|
||||||
leadNodeId={leadNodeId}
|
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
|
||||||
enabled={isActive}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
|
renderHud={(hudProps) => {
|
||||||
|
const extraHudProps = hudProps as typeof hudProps & {
|
||||||
|
getViewportSize?: () => { width: number; height: number };
|
||||||
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
|
getCameraZoom?: () => number;
|
||||||
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
|
};
|
||||||
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GraphActivityHud
|
||||||
|
teamName={teamName}
|
||||||
|
nodes={graphData.nodes}
|
||||||
|
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||||
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
|
getViewportSize={getViewportSize}
|
||||||
|
focusNodeIds={focusNodeIds}
|
||||||
|
enabled={isActive}
|
||||||
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
|
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
|
||||||
<GraphBlockingEdgePopover
|
<GraphBlockingEdgePopover
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -208,6 +226,8 @@ export const TeamGraphTab = ({
|
||||||
<TeamGraphOverlay
|
<TeamGraphOverlay
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
onClose={() => setFullscreen(false)}
|
onClose={() => setFullscreen(false)}
|
||||||
|
sidebarVisible={sidebarVisible}
|
||||||
|
onToggleSidebar={toggleSidebarVisible}
|
||||||
onSendMessage={dispatchSendMessage}
|
onSendMessage={dispatchSendMessage}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
5
src/features/recent-projects/contracts/api.ts
Normal file
5
src/features/recent-projects/contracts/api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { DashboardRecentProjectsPayload } from './dto';
|
||||||
|
|
||||||
|
export interface RecentProjectsElectronApi {
|
||||||
|
getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
|
||||||
|
}
|
||||||
2
src/features/recent-projects/contracts/channels.ts
Normal file
2
src/features/recent-projects/contracts/channels.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const GET_DASHBOARD_RECENT_PROJECTS = 'get-dashboard-recent-projects';
|
||||||
|
export const DASHBOARD_RECENT_PROJECTS_ROUTE = '/api/dashboard/recent-projects';
|
||||||
24
src/features/recent-projects/contracts/dto.ts
Normal file
24
src/features/recent-projects/contracts/dto.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
|
|
||||||
|
export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed';
|
||||||
|
|
||||||
|
export type DashboardRecentProjectOpenTarget =
|
||||||
|
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
||||||
|
| { type: 'synthetic-path'; path: string };
|
||||||
|
|
||||||
|
export interface DashboardRecentProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
primaryPath: string;
|
||||||
|
associatedPaths: string[];
|
||||||
|
mostRecentActivity: number;
|
||||||
|
providerIds: DashboardProviderId[];
|
||||||
|
source: DashboardRecentProjectSource;
|
||||||
|
openTarget: DashboardRecentProjectOpenTarget;
|
||||||
|
primaryBranch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRecentProjectsPayload {
|
||||||
|
projects: DashboardRecentProject[];
|
||||||
|
degraded: boolean;
|
||||||
|
}
|
||||||
4
src/features/recent-projects/contracts/index.ts
Normal file
4
src/features/recent-projects/contracts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type * from './api';
|
||||||
|
export * from './channels';
|
||||||
|
export type * from './dto';
|
||||||
|
export * from './normalize';
|
||||||
32
src/features/recent-projects/contracts/normalize.ts
Normal file
32
src/features/recent-projects/contracts/normalize.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto';
|
||||||
|
|
||||||
|
export type DashboardRecentProjectsPayloadLike =
|
||||||
|
| DashboardRecentProjectsPayload
|
||||||
|
| DashboardRecentProject[]
|
||||||
|
| { degraded?: unknown; projects?: unknown }
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export function normalizeDashboardRecentProjectsPayload(
|
||||||
|
value: DashboardRecentProjectsPayloadLike
|
||||||
|
): DashboardRecentProjectsPayload | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return {
|
||||||
|
projects: value,
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value.projects)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: value.projects,
|
||||||
|
degraded: value.degraded === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate';
|
||||||
|
|
||||||
|
export interface ListDashboardRecentProjectsResponse {
|
||||||
|
projects: RecentProjectAggregate[];
|
||||||
|
degraded: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ClockPort {
|
||||||
|
now(): number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
|
||||||
|
|
||||||
|
export interface ListDashboardRecentProjectsOutputPort<TViewModel> {
|
||||||
|
present(response: ListDashboardRecentProjectsResponse): TViewModel;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface LoggerPort {
|
||||||
|
info(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
error(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface RecentProjectsCachePort<T> {
|
||||||
|
get(key: string): Promise<T | null>;
|
||||||
|
getStale(key: string): Promise<T | null>;
|
||||||
|
set(key: string, value: T, ttlMs: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
|
||||||
|
|
||||||
|
export interface RecentProjectsSourceResult {
|
||||||
|
candidates: RecentProjectCandidate[];
|
||||||
|
degraded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[];
|
||||||
|
|
||||||
|
export interface RecentProjectsSourcePort {
|
||||||
|
readonly sourceId?: string;
|
||||||
|
readonly timeoutMs?: number;
|
||||||
|
list(): Promise<RecentProjectsSourcePayload>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { mergeRecentProjectCandidates } from '../../domain/policies/mergeRecentProjectCandidates';
|
||||||
|
|
||||||
|
import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate';
|
||||||
|
import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
|
||||||
|
import type { ClockPort } from '../ports/ClockPort';
|
||||||
|
import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort';
|
||||||
|
import type { LoggerPort } from '../ports/LoggerPort';
|
||||||
|
import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort';
|
||||||
|
import type {
|
||||||
|
RecentProjectsSourcePayload,
|
||||||
|
RecentProjectsSourcePort,
|
||||||
|
RecentProjectsSourceResult,
|
||||||
|
} from '../ports/RecentProjectsSourcePort';
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 10_000;
|
||||||
|
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||||
|
|
||||||
|
interface SourceLoadResult {
|
||||||
|
candidates: RecentProjectCandidate[];
|
||||||
|
degraded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSourcePayload(payload: RecentProjectsSourcePayload): RecentProjectsSourceResult {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return {
|
||||||
|
candidates: payload,
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates: payload.candidates,
|
||||||
|
degraded: payload.degraded === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDashboardRecentProjectsDeps<TViewModel> {
|
||||||
|
sources: RecentProjectsSourcePort[];
|
||||||
|
cache: RecentProjectsCachePort<TViewModel>;
|
||||||
|
output: ListDashboardRecentProjectsOutputPort<TViewModel>;
|
||||||
|
clock: ClockPort;
|
||||||
|
logger: LoggerPort;
|
||||||
|
cacheTtlMs?: number;
|
||||||
|
degradedCacheTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
|
readonly #cacheTtlMs: number;
|
||||||
|
readonly #degradedCacheTtlMs: number;
|
||||||
|
readonly #inFlightByCacheKey = new Map<string, Promise<TViewModel>>();
|
||||||
|
|
||||||
|
constructor(private readonly deps: ListDashboardRecentProjectsDeps<TViewModel>) {
|
||||||
|
this.#cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
this.#degradedCacheTtlMs = deps.degradedCacheTtlMs ?? DEFAULT_DEGRADED_CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(cacheKey: string): Promise<TViewModel> {
|
||||||
|
const cached = await this.deps.cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inFlight = this.#inFlightByCacheKey.get(cacheKey);
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = this.#executeUncached(cacheKey).finally(() => {
|
||||||
|
this.#inFlightByCacheKey.delete(cacheKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#inFlightByCacheKey.set(cacheKey, execution);
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #executeUncached(cacheKey: string): Promise<TViewModel> {
|
||||||
|
const startedAt = this.deps.clock.now();
|
||||||
|
const stale = await this.deps.cache.getStale(cacheKey);
|
||||||
|
const results = await Promise.all(
|
||||||
|
this.deps.sources.map((source, index) => this.#loadSource(source, index))
|
||||||
|
);
|
||||||
|
|
||||||
|
const successful = results.flatMap((result) => result.candidates);
|
||||||
|
const hasDegradedSources = results.some((result) => result.degraded);
|
||||||
|
const response: ListDashboardRecentProjectsResponse = {
|
||||||
|
projects: mergeRecentProjectCandidates(successful),
|
||||||
|
degraded: hasDegradedSources,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasDegradedSources && stale && response.projects.length === 0) {
|
||||||
|
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
|
||||||
|
this.deps.logger.info('recent-projects served stale cache', {
|
||||||
|
cacheKey,
|
||||||
|
degradedSources: results.filter((result) => result.degraded).length,
|
||||||
|
cacheTtlMs: this.#degradedCacheTtlMs,
|
||||||
|
durationMs: this.deps.clock.now() - startedAt,
|
||||||
|
});
|
||||||
|
return stale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewModel = this.deps.output.present(response);
|
||||||
|
const cacheTtlMs = hasDegradedSources
|
||||||
|
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||||
|
: this.#cacheTtlMs;
|
||||||
|
|
||||||
|
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||||
|
this.deps.logger.info('recent-projects loaded', {
|
||||||
|
cacheKey,
|
||||||
|
count: response.projects.length,
|
||||||
|
degradedSources: results.filter((result) => result.degraded).length,
|
||||||
|
cacheTtlMs,
|
||||||
|
durationMs: this.deps.clock.now() - startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadSource(
|
||||||
|
source: RecentProjectsSourcePort,
|
||||||
|
sourceIndex: number
|
||||||
|
): Promise<SourceLoadResult> {
|
||||||
|
const sourceId = source.sourceId ?? `source-${sourceIndex}`;
|
||||||
|
if (!source.timeoutMs || source.timeoutMs <= 0) {
|
||||||
|
return this.#loadSourceWithoutTimeout(source, sourceId, sourceIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
source
|
||||||
|
.list()
|
||||||
|
.then(
|
||||||
|
(payload) =>
|
||||||
|
({
|
||||||
|
kind: 'success',
|
||||||
|
payload: normalizeSourcePayload(payload),
|
||||||
|
}) as const
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
(error: unknown) =>
|
||||||
|
({
|
||||||
|
kind: 'error',
|
||||||
|
error,
|
||||||
|
}) as const
|
||||||
|
),
|
||||||
|
new Promise<{ kind: 'timeout' }>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve({ kind: 'timeout' }), source.timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.kind === 'success') {
|
||||||
|
return result.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === 'timeout') {
|
||||||
|
this.deps.logger.warn('recent-projects source timed out', {
|
||||||
|
sourceId,
|
||||||
|
sourceIndex,
|
||||||
|
timeoutMs: source.timeoutMs,
|
||||||
|
});
|
||||||
|
return { candidates: [], degraded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deps.logger.warn('recent-projects source failed', {
|
||||||
|
sourceId,
|
||||||
|
sourceIndex,
|
||||||
|
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||||
|
});
|
||||||
|
return { candidates: [], degraded: true };
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadSourceWithoutTimeout(
|
||||||
|
source: RecentProjectsSourcePort,
|
||||||
|
sourceId: string,
|
||||||
|
sourceIndex: number
|
||||||
|
): Promise<SourceLoadResult> {
|
||||||
|
try {
|
||||||
|
return normalizeSourcePayload(await source.list());
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.logger.warn('recent-projects source failed', {
|
||||||
|
sourceId,
|
||||||
|
sourceIndex,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return { candidates: [], degraded: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export type ProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ProviderId } from './ProviderId';
|
||||||
|
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||||
|
|
||||||
|
export interface RecentProjectAggregate {
|
||||||
|
identity: string;
|
||||||
|
displayName: string;
|
||||||
|
primaryPath: string;
|
||||||
|
associatedPaths: string[];
|
||||||
|
lastActivityAt: number;
|
||||||
|
providerIds: ProviderId[];
|
||||||
|
source: 'claude' | 'codex' | 'mixed';
|
||||||
|
openTarget: RecentProjectOpenTarget;
|
||||||
|
branchName?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ProviderId } from './ProviderId';
|
||||||
|
import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget';
|
||||||
|
|
||||||
|
export interface RecentProjectCandidate {
|
||||||
|
identity: string;
|
||||||
|
displayName: string;
|
||||||
|
primaryPath: string;
|
||||||
|
associatedPaths: string[];
|
||||||
|
lastActivityAt: number;
|
||||||
|
providerIds: ProviderId[];
|
||||||
|
sourceKind: 'claude' | 'codex';
|
||||||
|
openTarget: RecentProjectOpenTarget;
|
||||||
|
branchName?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type RecentProjectOpenTarget =
|
||||||
|
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
|
||||||
|
| { type: 'synthetic-path'; path: string };
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import type { ProviderId } from '../models/ProviderId';
|
||||||
|
import type { RecentProjectAggregate } from '../models/RecentProjectAggregate';
|
||||||
|
import type { RecentProjectCandidate } from '../models/RecentProjectCandidate';
|
||||||
|
|
||||||
|
function uniquePaths(paths: readonly string[], primaryPath: string): string[] {
|
||||||
|
const ordered = [primaryPath, ...paths];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const path of ordered) {
|
||||||
|
if (!path || seen.has(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(path);
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] {
|
||||||
|
return Array.from(new Set(providerIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreferredCandidate(
|
||||||
|
candidates: readonly RecentProjectCandidate[]
|
||||||
|
): RecentProjectCandidate {
|
||||||
|
const existingWorktreeCandidates = candidates.filter(
|
||||||
|
(candidate) => candidate.openTarget.type === 'existing-worktree'
|
||||||
|
);
|
||||||
|
const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates;
|
||||||
|
|
||||||
|
return [...pool].sort((left, right) => {
|
||||||
|
if (right.lastActivityAt !== left.lastActivityAt) {
|
||||||
|
return right.lastActivityAt - left.lastActivityAt;
|
||||||
|
}
|
||||||
|
return left.displayName.localeCompare(right.displayName);
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeBranchName(candidates: readonly RecentProjectCandidate[]): string | undefined {
|
||||||
|
const branchNames = Array.from(
|
||||||
|
new Set(candidates.map((candidate) => candidate.branchName?.trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
|
return branchNames.length === 1 ? branchNames[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRecentProjectCandidates(
|
||||||
|
candidates: readonly RecentProjectCandidate[]
|
||||||
|
): RecentProjectAggregate[] {
|
||||||
|
const grouped = new Map<string, RecentProjectCandidate[]>();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate.identity || candidate.lastActivityAt <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bucket = grouped.get(candidate.identity);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(candidate);
|
||||||
|
} else {
|
||||||
|
grouped.set(candidate.identity, [candidate]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregates = Array.from(grouped.values()).map((group): RecentProjectAggregate => {
|
||||||
|
const preferred = selectPreferredCandidate(group);
|
||||||
|
const providerIds = uniqueProviders(group.flatMap((candidate) => candidate.providerIds));
|
||||||
|
const sourceKinds = new Set(group.map((candidate) => candidate.sourceKind));
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: preferred.identity,
|
||||||
|
displayName: preferred.displayName,
|
||||||
|
primaryPath: preferred.primaryPath,
|
||||||
|
associatedPaths: uniquePaths(
|
||||||
|
group.flatMap((candidate) => candidate.associatedPaths),
|
||||||
|
preferred.primaryPath
|
||||||
|
),
|
||||||
|
lastActivityAt: Math.max(...group.map((candidate) => candidate.lastActivityAt)),
|
||||||
|
providerIds,
|
||||||
|
source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude',
|
||||||
|
openTarget: preferred.openTarget,
|
||||||
|
branchName: mergeBranchName(group),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return aggregates.sort((left, right) => right.lastActivityAt - left.lastActivityAt);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import {
|
||||||
|
DASHBOARD_RECENT_PROJECTS_ROUTE,
|
||||||
|
type DashboardRecentProjectsPayload,
|
||||||
|
normalizeDashboardRecentProjectsPayload,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
const logger = createLogger('Feature:RecentProjects:HTTP');
|
||||||
|
|
||||||
|
export function registerRecentProjectsHttp(
|
||||||
|
app: FastifyInstance,
|
||||||
|
feature: RecentProjectsFeatureFacade
|
||||||
|
): void {
|
||||||
|
app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise<DashboardRecentProjectsPayload> => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
|
||||||
|
projects: [],
|
||||||
|
degraded: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load dashboard recent projects via HTTP', error);
|
||||||
|
return { projects: [], degraded: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {
|
||||||
|
GET_DASHBOARD_RECENT_PROJECTS,
|
||||||
|
normalizeDashboardRecentProjectsPayload,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
|
||||||
|
import type { IpcMain } from 'electron';
|
||||||
|
|
||||||
|
const logger = createLogger('Feature:RecentProjects:IPC');
|
||||||
|
|
||||||
|
export function registerRecentProjectsIpc(
|
||||||
|
ipcMain: IpcMain,
|
||||||
|
feature: RecentProjectsFeatureFacade
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
|
||||||
|
projects: [],
|
||||||
|
degraded: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load dashboard recent projects via IPC', error);
|
||||||
|
return { projects: [], degraded: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRecentProjectsIpc(ipcMain: IpcMain): void {
|
||||||
|
ipcMain.removeHandler(GET_DASHBOARD_RECENT_PROJECTS);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type {
|
||||||
|
DashboardRecentProject,
|
||||||
|
DashboardRecentProjectsPayload,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse';
|
||||||
|
import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort';
|
||||||
|
|
||||||
|
export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort<DashboardRecentProjectsPayload> {
|
||||||
|
present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload {
|
||||||
|
return {
|
||||||
|
degraded: response.degraded,
|
||||||
|
projects: response.projects.map(
|
||||||
|
(aggregate): DashboardRecentProject => ({
|
||||||
|
id: aggregate.identity,
|
||||||
|
name: aggregate.displayName,
|
||||||
|
primaryPath: aggregate.primaryPath,
|
||||||
|
associatedPaths: aggregate.associatedPaths,
|
||||||
|
mostRecentActivity: aggregate.lastActivityAt,
|
||||||
|
providerIds: aggregate.providerIds,
|
||||||
|
source: aggregate.source,
|
||||||
|
openTarget: aggregate.openTarget,
|
||||||
|
primaryBranch: aggregate.branchName,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
|
import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper';
|
||||||
|
import { getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
|
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||||
|
import type {
|
||||||
|
RecentProjectsSourcePort,
|
||||||
|
RecentProjectsSourceResult,
|
||||||
|
} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort';
|
||||||
|
import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
|
||||||
|
import type { ServiceContext } from '@main/services';
|
||||||
|
import type { RepositoryGroup, Worktree } from '@main/types';
|
||||||
|
|
||||||
|
function selectPreferredWorktree(worktrees: readonly Worktree[]): Worktree | undefined {
|
||||||
|
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null {
|
||||||
|
if (!repo.worktrees.length || !repo.mostRecentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredWorktree = selectPreferredWorktree(repo.worktrees);
|
||||||
|
if (!preferredWorktree) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: repo.identity?.id ?? `path:${normalizeIdentityPath(preferredWorktree.path)}`,
|
||||||
|
displayName: repo.name,
|
||||||
|
primaryPath: preferredWorktree.path,
|
||||||
|
associatedPaths: repo.worktrees.map((worktree) => worktree.path),
|
||||||
|
lastActivityAt: repo.mostRecentSession,
|
||||||
|
providerIds: ['anthropic'],
|
||||||
|
sourceKind: 'claude',
|
||||||
|
openTarget: {
|
||||||
|
type: 'existing-worktree',
|
||||||
|
repositoryId: repo.id,
|
||||||
|
worktreeId: preferredWorktree.id,
|
||||||
|
},
|
||||||
|
branchName: preferredWorktree.gitBranch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||||
|
readonly #localWorktreeGrouper = new WorktreeGrouper(getProjectsBasePath());
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly getActiveContext: () => ServiceContext,
|
||||||
|
private readonly logger: LoggerPort
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<RecentProjectsSourceResult> {
|
||||||
|
const activeContext = this.getActiveContext();
|
||||||
|
const groups =
|
||||||
|
activeContext.type === 'local'
|
||||||
|
? await this.#groupLocalProjects(activeContext)
|
||||||
|
: await activeContext.projectScanner.scanWithWorktreeGrouping();
|
||||||
|
|
||||||
|
const candidates = groups
|
||||||
|
.map((group) => toCandidate(group))
|
||||||
|
.filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||||
|
|
||||||
|
this.logger.info('claude recent-projects source loaded', {
|
||||||
|
count: candidates.length,
|
||||||
|
contextId: activeContext.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates,
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #groupLocalProjects(activeContext: ServiceContext): Promise<RepositoryGroup[]> {
|
||||||
|
try {
|
||||||
|
const projects = await activeContext.projectScanner.scan();
|
||||||
|
return await this.#localWorktreeGrouper.groupByRepository(projects);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('claude recent-projects fell back to simplified grouping', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return activeContext.projectScanner.scanWithWorktreeGrouping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
|
||||||
|
import type {
|
||||||
|
RecentProjectsSourcePort,
|
||||||
|
RecentProjectsSourceResult,
|
||||||
|
} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort';
|
||||||
|
import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
|
||||||
|
import type {
|
||||||
|
CodexAppServerClient,
|
||||||
|
CodexRecentThreadsResult,
|
||||||
|
CodexThreadSummary,
|
||||||
|
} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||||
|
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
|
import type { ServiceContext } from '@main/services';
|
||||||
|
|
||||||
|
const CODEX_THREAD_LIMIT = 40;
|
||||||
|
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||||
|
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||||
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||||
|
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||||
|
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||||
|
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||||
|
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||||
|
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||||
|
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||||
|
|
||||||
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
|
return source === 'vscode' || source === 'cli';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: number | undefined): number {
|
||||||
|
if (!value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return value < 1_000_000_000_000 ? value * 1000 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
||||||
|
return Boolean(result.live.error || result.archived.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||||
|
readonly sourceId = 'codex';
|
||||||
|
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
getActiveContext: () => ServiceContext;
|
||||||
|
getLocalContext: () => ServiceContext | undefined;
|
||||||
|
resolveBinary: () => Promise<string | null>;
|
||||||
|
appServerClient: CodexAppServerClient;
|
||||||
|
identityResolver: RecentProjectIdentityResolver;
|
||||||
|
logger: LoggerPort;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<RecentProjectsSourceResult> {
|
||||||
|
const activeContext = this.deps.getActiveContext();
|
||||||
|
const localContext = this.deps.getLocalContext();
|
||||||
|
|
||||||
|
if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) {
|
||||||
|
return {
|
||||||
|
candidates: [],
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryPath = await this.deps.resolveBinary();
|
||||||
|
if (!binaryPath) {
|
||||||
|
this.deps.logger.info('codex recent-projects source skipped - binary unavailable');
|
||||||
|
return {
|
||||||
|
candidates: [],
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||||
|
const degraded = isDegradedThreadResult(threadSegments);
|
||||||
|
this.#logSegmentFailure(threadSegments, 'live');
|
||||||
|
this.#logSegmentFailure(threadSegments, 'archived');
|
||||||
|
const liveThreads = threadSegments.live.threads;
|
||||||
|
const archivedThreads = threadSegments.archived.threads;
|
||||||
|
|
||||||
|
const interactiveThreads = [...liveThreads, ...archivedThreads].filter(
|
||||||
|
(thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source)
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = (
|
||||||
|
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||||
|
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||||
|
|
||||||
|
this.deps.logger.info('codex recent-projects source loaded', {
|
||||||
|
count: candidates.length,
|
||||||
|
degraded,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates,
|
||||||
|
degraded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
|
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||||
|
limit: CODEX_THREAD_LIMIT,
|
||||||
|
liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||||
|
archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
|
||||||
|
initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS,
|
||||||
|
totalTimeoutMs: CODEX_TOTAL_FETCH_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.logger.info('codex recent-projects thread lists loaded', {
|
||||||
|
liveCount: result.live.threads.length,
|
||||||
|
archivedCount: result.archived.threads.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void {
|
||||||
|
const error = result[segment].error;
|
||||||
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment === 'archived' && !result.live.error) {
|
||||||
|
this.deps.logger.info('codex recent-projects archived thread list degraded', {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deps.logger.warn('codex recent-projects thread list failed', {
|
||||||
|
segment,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #listRecentThreadsSafe(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
|
try {
|
||||||
|
return await this.#listRecentThreads(binaryPath);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.deps.logger.warn('codex recent-projects thread list session failed', {
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.toLowerCase().includes('timed out')) {
|
||||||
|
return {
|
||||||
|
live: { threads: [], error: message },
|
||||||
|
archived: { threads: [], error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, {
|
||||||
|
limit: CODEX_THREAD_LIMIT,
|
||||||
|
requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||||
|
initializeTimeoutMs: CODEX_INITIALIZE_TIMEOUT_MS,
|
||||||
|
totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.logger.info('codex recent-projects recovered with live-only fallback', {
|
||||||
|
liveCount: liveFallback.threads.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
live: liveFallback,
|
||||||
|
archived: { threads: [], error: message },
|
||||||
|
};
|
||||||
|
} catch (fallbackError) {
|
||||||
|
const fallbackMessage =
|
||||||
|
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
this.deps.logger.warn('codex recent-projects live-only fallback failed', {
|
||||||
|
error: fallbackMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
live: { threads: [], error: message },
|
||||||
|
archived: { threads: [], error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #toCandidate(thread: CodexThreadSummary): Promise<RecentProjectCandidate | null> {
|
||||||
|
const cwd = thread.cwd?.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = await this.deps.identityResolver.resolve(cwd);
|
||||||
|
const displayName = identity?.name ?? path.basename(cwd) ?? thread.name?.trim() ?? cwd;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: identity?.id ?? `path:${normalizeIdentityPath(cwd)}`,
|
||||||
|
displayName,
|
||||||
|
primaryPath: cwd,
|
||||||
|
associatedPaths: [cwd],
|
||||||
|
lastActivityAt: normalizeTimestamp(thread.updatedAt ?? thread.createdAt),
|
||||||
|
providerIds: ['codex'],
|
||||||
|
sourceKind: 'codex',
|
||||||
|
openTarget: {
|
||||||
|
type: 'synthetic-path',
|
||||||
|
path: cwd,
|
||||||
|
},
|
||||||
|
branchName: thread.gitInfo?.branch ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
type DashboardRecentProjectsPayload,
|
||||||
|
normalizeDashboardRecentProjectsPayload,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
|
||||||
|
import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter';
|
||||||
|
import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter';
|
||||||
|
import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter';
|
||||||
|
import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache';
|
||||||
|
import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient';
|
||||||
|
import { CodexBinaryResolver } from '../infrastructure/codex/CodexBinaryResolver';
|
||||||
|
import { JsonRpcStdioClient } from '../infrastructure/codex/JsonRpcStdioClient';
|
||||||
|
import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
|
|
||||||
|
import type { ClockPort } from '../../core/application/ports/ClockPort';
|
||||||
|
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||||
|
import type { ServiceContext } from '@main/services';
|
||||||
|
|
||||||
|
export interface RecentProjectsFeatureFacade {
|
||||||
|
listDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecentProjectsFeature(deps: {
|
||||||
|
getActiveContext: () => ServiceContext;
|
||||||
|
getLocalContext: () => ServiceContext | undefined;
|
||||||
|
logger: LoggerPort;
|
||||||
|
}): RecentProjectsFeatureFacade {
|
||||||
|
const cache = new InMemoryRecentProjectsCache<DashboardRecentProjectsPayload>();
|
||||||
|
const presenter = new DashboardRecentProjectsPresenter();
|
||||||
|
const clock: ClockPort = { now: () => Date.now() };
|
||||||
|
const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger);
|
||||||
|
const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient);
|
||||||
|
const identityResolver = new RecentProjectIdentityResolver();
|
||||||
|
const sources = [
|
||||||
|
new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger),
|
||||||
|
new CodexRecentProjectsSourceAdapter({
|
||||||
|
getActiveContext: deps.getActiveContext,
|
||||||
|
getLocalContext: deps.getLocalContext,
|
||||||
|
resolveBinary: () => CodexBinaryResolver.resolve(),
|
||||||
|
appServerClient: codexAppServerClient,
|
||||||
|
identityResolver,
|
||||||
|
logger: deps.logger,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const useCase = new ListDashboardRecentProjectsUseCase({
|
||||||
|
sources,
|
||||||
|
cache,
|
||||||
|
output: presenter,
|
||||||
|
clock,
|
||||||
|
logger: deps.logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
listDashboardRecentProjects: async () => {
|
||||||
|
const activeContext = deps.getActiveContext();
|
||||||
|
const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`);
|
||||||
|
return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
7
src/features/recent-projects/main/index.ts
Normal file
7
src/features/recent-projects/main/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { registerRecentProjectsHttp } from './adapters/input/http/registerRecentProjectsHttp';
|
||||||
|
export {
|
||||||
|
registerRecentProjectsIpc,
|
||||||
|
removeRecentProjectsIpc,
|
||||||
|
} from './adapters/input/ipc/registerRecentProjectsIpc';
|
||||||
|
export type { RecentProjectsFeatureFacade } from './composition/createRecentProjectsFeature';
|
||||||
|
export { createRecentProjectsFeature } from './composition/createRecentProjectsFeature';
|
||||||
34
src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
vendored
Normal file
34
src/features/recent-projects/main/infrastructure/cache/InMemoryRecentProjectsCache.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { RecentProjectsCachePort } from '../../../core/application/ports/RecentProjectsCachePort';
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryRecentProjectsCache<T> implements RecentProjectsCachePort<T> {
|
||||||
|
readonly #entries = new Map<string, CacheEntry<T>>();
|
||||||
|
|
||||||
|
async get(key: string): Promise<T | null> {
|
||||||
|
const entry = this.#entries.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStale(key: string): Promise<T | null> {
|
||||||
|
return this.#entries.get(key)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: T, ttlMs: number): Promise<void> {
|
||||||
|
this.#entries.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||||
|
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||||
|
const DEFAULT_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||||
|
const MIN_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||||
|
const SUPPRESSED_NOTIFICATION_METHODS = [
|
||||||
|
'thread/started',
|
||||||
|
'thread/status/changed',
|
||||||
|
'thread/archived',
|
||||||
|
'thread/unarchived',
|
||||||
|
'thread/closed',
|
||||||
|
'thread/name/updated',
|
||||||
|
'turn/started',
|
||||||
|
'turn/completed',
|
||||||
|
'item/agentMessage/delta',
|
||||||
|
'item/agentReasoning/delta',
|
||||||
|
'item/execCommandOutputDelta',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ThreadListResponse {
|
||||||
|
data?: CodexThreadSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexGitInfo {
|
||||||
|
branch?: string | null;
|
||||||
|
originUrl?: string | null;
|
||||||
|
sha?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexThreadSummary {
|
||||||
|
id: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
cwd?: string | null;
|
||||||
|
source?: unknown;
|
||||||
|
modelProvider?: string | null;
|
||||||
|
gitInfo?: CodexGitInfo | null;
|
||||||
|
name?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexThreadSegmentResult {
|
||||||
|
threads: CodexThreadSummary[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRecentThreadsResult {
|
||||||
|
live: CodexThreadSegmentResult;
|
||||||
|
archived: CodexThreadSegmentResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadListSessionOptions {
|
||||||
|
binaryPath: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
initializeTimeoutMs: number;
|
||||||
|
totalTimeoutMs: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexAppServerClient {
|
||||||
|
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
||||||
|
|
||||||
|
async listRecentLiveThreads(
|
||||||
|
binaryPath: string,
|
||||||
|
options: {
|
||||||
|
limit: number;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
initializeTimeoutMs?: number;
|
||||||
|
totalTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
): Promise<CodexThreadSegmentResult> {
|
||||||
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const initializeTimeoutMs = Math.max(
|
||||||
|
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||||
|
requestTimeoutMs
|
||||||
|
);
|
||||||
|
const totalTimeoutMs = Math.max(
|
||||||
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
|
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.#withThreadListSession(
|
||||||
|
{
|
||||||
|
binaryPath,
|
||||||
|
requestTimeoutMs,
|
||||||
|
initializeTimeoutMs,
|
||||||
|
totalTimeoutMs,
|
||||||
|
label: 'codex app-server thread/list live',
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
const live = await session.request<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: false,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
requestTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads: live.data ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRecentThreads(
|
||||||
|
binaryPath: string,
|
||||||
|
options: {
|
||||||
|
limit: number;
|
||||||
|
liveRequestTimeoutMs?: number;
|
||||||
|
archivedRequestTimeoutMs?: number;
|
||||||
|
initializeTimeoutMs?: number;
|
||||||
|
totalTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
): Promise<CodexRecentThreadsResult> {
|
||||||
|
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||||
|
const initializeTimeoutMs = Math.max(
|
||||||
|
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||||
|
sessionRequestTimeoutMs
|
||||||
|
);
|
||||||
|
const totalTimeoutMs = Math.max(
|
||||||
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
|
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.#withThreadListSession(
|
||||||
|
{
|
||||||
|
binaryPath,
|
||||||
|
requestTimeoutMs: sessionRequestTimeoutMs,
|
||||||
|
initializeTimeoutMs,
|
||||||
|
totalTimeoutMs,
|
||||||
|
label: 'codex app-server thread/list',
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
const [live, archived] = await Promise.allSettled([
|
||||||
|
session.request<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: false,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
liveRequestTimeoutMs
|
||||||
|
),
|
||||||
|
session.request<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: true,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
archivedRequestTimeoutMs
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
live:
|
||||||
|
live.status === 'fulfilled'
|
||||||
|
? { threads: live.value.data ?? [] }
|
||||||
|
: {
|
||||||
|
threads: [],
|
||||||
|
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
||||||
|
},
|
||||||
|
archived:
|
||||||
|
archived.status === 'fulfilled'
|
||||||
|
? { threads: archived.value.data ?? [] }
|
||||||
|
: {
|
||||||
|
threads: [],
|
||||||
|
error:
|
||||||
|
archived.reason instanceof Error
|
||||||
|
? archived.reason.message
|
||||||
|
: String(archived.reason),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #withThreadListSession<T>(
|
||||||
|
options: ThreadListSessionOptions,
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return this.rpcClient.withSession(
|
||||||
|
{
|
||||||
|
binaryPath: options.binaryPath,
|
||||||
|
args: ['app-server'],
|
||||||
|
requestTimeoutMs: options.requestTimeoutMs,
|
||||||
|
totalTimeoutMs: options.totalTimeoutMs,
|
||||||
|
label: options.label,
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
await session.request(
|
||||||
|
'initialize',
|
||||||
|
{
|
||||||
|
clientInfo: {
|
||||||
|
name: 'claude-agent-teams-ui',
|
||||||
|
title: 'Claude Agent Teams UI',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
experimentalApi: false,
|
||||||
|
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options.initializeTimeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.notify('initialized');
|
||||||
|
return handler(session);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { constants as fsConstants } from 'node:fs';
|
||||||
|
import * as fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
let cachedBinaryPath: string | null | undefined;
|
||||||
|
let cacheVerifiedAt = 0;
|
||||||
|
let resolveInFlight: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath, fsConstants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandWindowsExtensions(candidate: string): string[] {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return [candidate];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathext = process.env.PATHEXT?.split(';').filter(Boolean) ?? [
|
||||||
|
'.EXE',
|
||||||
|
'.CMD',
|
||||||
|
'.BAT',
|
||||||
|
'.COM',
|
||||||
|
];
|
||||||
|
const hasKnownExtension = pathext.some((ext) =>
|
||||||
|
candidate.toLowerCase().endsWith(ext.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasKnownExtension) {
|
||||||
|
return [candidate];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBinary(candidate: string): Promise<string | null> {
|
||||||
|
const expandedCandidates = expandWindowsExtensions(candidate);
|
||||||
|
|
||||||
|
if (path.isAbsolute(candidate) || candidate.includes(path.sep)) {
|
||||||
|
for (const expandedCandidate of expandedCandidates) {
|
||||||
|
if (await fileExists(expandedCandidate)) {
|
||||||
|
return expandedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
||||||
|
for (const pathEntry of pathEntries) {
|
||||||
|
for (const expandedCandidate of expandedCandidates) {
|
||||||
|
const resolvedCandidate = path.join(pathEntry, expandedCandidate);
|
||||||
|
if (await fileExists(resolvedCandidate)) {
|
||||||
|
return resolvedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexBinaryResolver {
|
||||||
|
static clearCache(): void {
|
||||||
|
cachedBinaryPath = undefined;
|
||||||
|
cacheVerifiedAt = 0;
|
||||||
|
resolveInFlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async resolve(): Promise<string | null> {
|
||||||
|
if (cachedBinaryPath !== undefined) {
|
||||||
|
if (cachedBinaryPath === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
|
||||||
|
return cachedBinaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyBinary(cachedBinaryPath);
|
||||||
|
if (verified) {
|
||||||
|
cacheVerifiedAt = Date.now();
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBinaryPath = undefined;
|
||||||
|
cacheVerifiedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolveInFlight) {
|
||||||
|
resolveInFlight = CodexBinaryResolver.runResolve().finally(() => {
|
||||||
|
resolveInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async runResolve(): Promise<string | null> {
|
||||||
|
const override = process.env.CODEX_CLI_PATH?.trim();
|
||||||
|
const candidates = override ? [override, 'codex'] : ['codex'];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const resolved = await verifyBinary(candidate);
|
||||||
|
if (resolved) {
|
||||||
|
cachedBinaryPath = resolved;
|
||||||
|
cacheVerifiedAt = Date.now();
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBinaryPath = null;
|
||||||
|
cacheVerifiedAt = Date.now();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
|
||||||
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
|
|
||||||
|
import type { LoggerPort } from '../../../core/application/ports/LoggerPort';
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||||
|
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
interface JsonRpcErrorPayload {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonRpcResponse<T> {
|
||||||
|
id?: number;
|
||||||
|
result?: T;
|
||||||
|
error?: JsonRpcErrorPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonRpcSession {
|
||||||
|
request<TResult>(method: string, params?: unknown, timeoutMs?: number): Promise<TResult>;
|
||||||
|
notify(method: string, params?: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(
|
||||||
|
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonRpcStdioClient {
|
||||||
|
constructor(private readonly logger: LoggerPort) {}
|
||||||
|
|
||||||
|
async withSession<T>(
|
||||||
|
options: {
|
||||||
|
binaryPath: string;
|
||||||
|
args: string[];
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
totalTimeoutMs?: number;
|
||||||
|
label: string;
|
||||||
|
},
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return withTimeout(
|
||||||
|
this.#runSession(options.binaryPath, options.args, requestTimeoutMs, handler),
|
||||||
|
totalTimeoutMs,
|
||||||
|
options.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runSession<T>(
|
||||||
|
binaryPath: string,
|
||||||
|
args: string[],
|
||||||
|
requestTimeoutMs: number,
|
||||||
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const child = spawnCli(binaryPath, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const lineReader = readline.createInterface({ input: child.stdout! });
|
||||||
|
child.stderr?.on('data', () => {
|
||||||
|
// Keep stderr drained so process warnings do not block the pipe.
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
let nextRequestId = 1;
|
||||||
|
|
||||||
|
const rejectAll = (error: Error): void => {
|
||||||
|
for (const [id, entry] of pending) {
|
||||||
|
clearTimeout(entry.timeoutId);
|
||||||
|
entry.reject(error);
|
||||||
|
pending.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lineReader.on('line', (line) => {
|
||||||
|
let message: JsonRpcResponse<unknown>;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line) as JsonRpcResponse<unknown>;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('json-rpc stdio emitted non-json line', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message.id !== 'number') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = pending.get(message.id);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(entry.timeoutId);
|
||||||
|
pending.delete(message.id);
|
||||||
|
|
||||||
|
if (message.error) {
|
||||||
|
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.resolve(message.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once('error', (error) => {
|
||||||
|
rejectAll(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
if (pending.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAll(
|
||||||
|
new Error(
|
||||||
|
`JSON-RPC process exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'null'})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const session: JsonRpcSession = {
|
||||||
|
request: <TResult>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
timeoutMs = requestTimeoutMs
|
||||||
|
): Promise<TResult> =>
|
||||||
|
new Promise<TResult>((resolve, reject) => {
|
||||||
|
if (!child.stdin) {
|
||||||
|
reject(new Error('JSON-RPC stdin is not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nextRequestId++;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`JSON-RPC request timed out: ${method}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId });
|
||||||
|
|
||||||
|
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
|
||||||
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
pending.delete(id);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
notify: async (method: string, params?: unknown): Promise<void> => {
|
||||||
|
if (!child.stdin) {
|
||||||
|
throw new Error('JSON-RPC stdin is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await handler(session);
|
||||||
|
} finally {
|
||||||
|
rejectAll(new Error('JSON-RPC session closed'));
|
||||||
|
lineReader.close();
|
||||||
|
if (child.stdin && !child.stdin.destroyed) {
|
||||||
|
child.stdin.end();
|
||||||
|
}
|
||||||
|
killProcessTree(child);
|
||||||
|
try {
|
||||||
|
await once(child, 'close');
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('json-rpc close wait failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { gitIdentityResolver } from '@main/services/parsing/GitIdentityResolver';
|
||||||
|
|
||||||
|
export interface RecentProjectIdentity {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecentProjectIdentityResolver {
|
||||||
|
async resolve(projectPath: string): Promise<RecentProjectIdentity | null> {
|
||||||
|
const identity = await gitIdentityResolver.resolveIdentity(projectPath);
|
||||||
|
if (!identity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function normalizeIdentityPath(projectPath: string): string {
|
||||||
|
let normalized = path.normalize(projectPath);
|
||||||
|
while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import {
|
||||||
|
GET_DASHBOARD_RECENT_PROJECTS,
|
||||||
|
type RecentProjectsElectronApi,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
export function createRecentProjectsBridge(): RecentProjectsElectronApi {
|
||||||
|
return {
|
||||||
|
getDashboardRecentProjects: () => ipcRenderer.invoke(GET_DASHBOARD_RECENT_PROJECTS),
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/recent-projects/preload/index.ts
Normal file
1
src/features/recent-projects/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { createRecentProjectsBridge } from './createRecentProjectsBridge';
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
|
import { normalizePath, type TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import { sortDashboardProviderIds } from '../utils/projectDecorations';
|
||||||
|
|
||||||
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
import type { TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
|
export interface RecentProjectCardModel {
|
||||||
|
id: string;
|
||||||
|
project: DashboardRecentProject;
|
||||||
|
name: string;
|
||||||
|
formattedPath: string;
|
||||||
|
lastActivityLabel: string;
|
||||||
|
providerIds: DashboardRecentProject['providerIds'];
|
||||||
|
primaryBranch?: string;
|
||||||
|
taskCounts?: TaskStatusCounts;
|
||||||
|
tasksLoading: boolean;
|
||||||
|
activeTeams?: TeamSummary[];
|
||||||
|
additionalPathCount: number;
|
||||||
|
pathSummary?: {
|
||||||
|
badgeLabel: string;
|
||||||
|
description: string;
|
||||||
|
paths: {
|
||||||
|
label: string;
|
||||||
|
fullPath: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentProjectsSectionAdapterInput {
|
||||||
|
projects: DashboardRecentProject[];
|
||||||
|
taskCountsByProject: Map<string, TaskStatusCounts>;
|
||||||
|
activeTeamsByProject: Map<string, TeamSummary[]>;
|
||||||
|
tasksLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumTaskCounts(
|
||||||
|
project: DashboardRecentProject,
|
||||||
|
taskCountsByProject: Map<string, TaskStatusCounts>
|
||||||
|
): TaskStatusCounts | undefined {
|
||||||
|
const total = project.associatedPaths.reduce<TaskStatusCounts>(
|
||||||
|
(counts, currentPath) => {
|
||||||
|
const next = taskCountsByProject.get(normalizePath(currentPath));
|
||||||
|
if (!next) {
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: counts.pending + next.pending,
|
||||||
|
inProgress: counts.inProgress + next.inProgress,
|
||||||
|
completed: counts.completed + next.completed,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ pending: 0, inProgress: 0, completed: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return total.pending > 0 || total.inProgress > 0 || total.completed > 0 ? total : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectActiveTeams(
|
||||||
|
project: DashboardRecentProject,
|
||||||
|
activeTeamsByProject: Map<string, TeamSummary[]>
|
||||||
|
): TeamSummary[] | undefined {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const activeTeams: TeamSummary[] = [];
|
||||||
|
|
||||||
|
for (const projectPath of project.associatedPaths) {
|
||||||
|
const teams = activeTeamsByProject.get(normalizePath(projectPath));
|
||||||
|
if (!teams) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
if (seen.has(team.teamName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(team.teamName);
|
||||||
|
activeTeams.push(team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeTeams.length > 0 ? activeTeams : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPathSummary(
|
||||||
|
project: DashboardRecentProject
|
||||||
|
): RecentProjectCardModel['pathSummary'] | undefined {
|
||||||
|
const orderedPaths = [project.primaryPath, ...project.associatedPaths].filter(Boolean);
|
||||||
|
const uniquePaths = Array.from(new Set(orderedPaths));
|
||||||
|
|
||||||
|
if (uniquePaths.length <= 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badgeLabel: `${uniquePaths.length} paths`,
|
||||||
|
description: 'This card merges recent activity from related worktrees and project paths.',
|
||||||
|
paths: uniquePaths.map((fullPath, index) => ({
|
||||||
|
label: index === 0 ? 'Primary path' : `Related path ${index}`,
|
||||||
|
fullPath,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptRecentProjectsSection({
|
||||||
|
projects,
|
||||||
|
taskCountsByProject,
|
||||||
|
activeTeamsByProject,
|
||||||
|
tasksLoading,
|
||||||
|
}: RecentProjectsSectionAdapterInput): RecentProjectCardModel[] {
|
||||||
|
return projects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
project,
|
||||||
|
name: project.name,
|
||||||
|
formattedPath: formatProjectPath(project.primaryPath),
|
||||||
|
lastActivityLabel: formatDistanceToNow(new Date(project.mostRecentActivity), {
|
||||||
|
addSuffix: true,
|
||||||
|
}),
|
||||||
|
providerIds: sortDashboardProviderIds(project.providerIds),
|
||||||
|
primaryBranch: project.primaryBranch,
|
||||||
|
taskCounts: sumTaskCounts(project, taskCountsByProject),
|
||||||
|
tasksLoading,
|
||||||
|
activeTeams: collectActiveTeams(project, activeTeamsByProject),
|
||||||
|
additionalPathCount: Math.max(0, project.associatedPaths.length - 1),
|
||||||
|
pathSummary: buildPathSummary(project),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DashboardRecentProject,
|
||||||
|
type DashboardRecentProjectOpenTarget,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSyntheticRepositoryGroup,
|
||||||
|
findMatchingWorktree,
|
||||||
|
type WorktreeMatch,
|
||||||
|
} from '../utils/navigation';
|
||||||
|
import { recordRecentProjectOpenPaths } from '../utils/recentProjectOpenHistory';
|
||||||
|
|
||||||
|
const logger = createLogger('Feature:RecentProjects:open');
|
||||||
|
|
||||||
|
export function useOpenRecentProject(): {
|
||||||
|
openRecentProject: (project: DashboardRecentProject) => Promise<void>;
|
||||||
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
|
selectProjectFolder: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
repositoryGroups: state.repositoryGroups,
|
||||||
|
fetchRepositoryGroups: state.fetchRepositoryGroups,
|
||||||
|
openTeamsTab: state.openTeamsTab,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateToMatch = useCallback(
|
||||||
|
(match: WorktreeMatch): void => {
|
||||||
|
useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId));
|
||||||
|
void useStore.getState().fetchSessionsInitial(match.worktreeId);
|
||||||
|
openTeamsTab();
|
||||||
|
},
|
||||||
|
[openTeamsTab]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openSyntheticPath = useCallback(
|
||||||
|
async (path: string, associatedPaths: readonly string[]): Promise<void> => {
|
||||||
|
const candidatePaths = associatedPaths.length > 0 ? associatedPaths : [path];
|
||||||
|
|
||||||
|
const initialMatch = findMatchingWorktree(repositoryGroups, candidatePaths);
|
||||||
|
if (initialMatch) {
|
||||||
|
navigateToMatch(initialMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchRepositoryGroups();
|
||||||
|
const refreshedGroups = useStore.getState().repositoryGroups;
|
||||||
|
const refreshedMatch = findMatchingWorktree(refreshedGroups, candidatePaths);
|
||||||
|
if (refreshedMatch) {
|
||||||
|
navigateToMatch(refreshedMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.config.addCustomProjectPath(path);
|
||||||
|
|
||||||
|
useStore.setState((state) => ({
|
||||||
|
repositoryGroups: [buildSyntheticRepositoryGroup(path), ...state.repositoryGroups],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const encodedId = path.replace(/[/\\]/g, '-');
|
||||||
|
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
|
||||||
|
},
|
||||||
|
[fetchRepositoryGroups, navigateToMatch, repositoryGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTarget = useCallback(
|
||||||
|
async (
|
||||||
|
target: DashboardRecentProjectOpenTarget,
|
||||||
|
associatedPaths: readonly string[]
|
||||||
|
): Promise<void> => {
|
||||||
|
if (target.type === 'existing-worktree') {
|
||||||
|
navigateToMatch({
|
||||||
|
repoId: target.repositoryId,
|
||||||
|
worktreeId: target.worktreeId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSyntheticPath(target.path, associatedPaths);
|
||||||
|
},
|
||||||
|
[navigateToMatch, openSyntheticPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRecentProject = useCallback(
|
||||||
|
async (project: DashboardRecentProject): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await openTarget(project.openTarget, project.associatedPaths);
|
||||||
|
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open recent project', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openProjectPath = useCallback(async (projectPath: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await api.openPath(projectPath, projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to open project path', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectProjectFolder = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const selectedPaths = await api.config.selectFolders();
|
||||||
|
const selectedPath = selectedPaths[0];
|
||||||
|
if (!selectedPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSyntheticPath(selectedPath, [selectedPath]);
|
||||||
|
recordRecentProjectOpenPaths([selectedPath]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to select project folder', error);
|
||||||
|
}
|
||||||
|
}, [openSyntheticPath]);
|
||||||
|
|
||||||
|
return { openRecentProject, openProjectPath, selectProjectFolder };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
import {
|
||||||
|
sortRecentProjectsByDisplayPriority,
|
||||||
|
subscribeRecentProjectOpenHistory,
|
||||||
|
} from '../utils/recentProjectOpenHistory';
|
||||||
|
import {
|
||||||
|
getRecentProjectsClientSnapshot,
|
||||||
|
loadRecentProjectsWithClientCache,
|
||||||
|
} from '../utils/recentProjectsClientCache';
|
||||||
|
|
||||||
|
import { useOpenRecentProject } from './useOpenRecentProject';
|
||||||
|
|
||||||
|
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
import type { TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
|
const INITIAL_RECENT_PROJECTS = 11;
|
||||||
|
const LOAD_MORE_STEP = 8;
|
||||||
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
||||||
|
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
||||||
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||||
|
|
||||||
|
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
project.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
project.primaryPath.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
project.associatedPaths.some((projectPath) =>
|
||||||
|
projectPath.toLowerCase().includes(normalizedQuery)
|
||||||
|
) ||
|
||||||
|
project.primaryBranch?.toLowerCase().includes(normalizedQuery) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentProjectsSection(
|
||||||
|
searchQuery: string,
|
||||||
|
maxProjects = INITIAL_RECENT_PROJECTS
|
||||||
|
): {
|
||||||
|
cards: RecentProjectCardModel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
canLoadMore: boolean;
|
||||||
|
isElectron: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
openRecentProject: (project: DashboardRecentProject) => Promise<void>;
|
||||||
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
|
selectProjectFolder: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
||||||
|
useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
globalTasks: state.globalTasks,
|
||||||
|
globalTasksInitialized: state.globalTasksInitialized,
|
||||||
|
globalTasksLoading: state.globalTasksLoading,
|
||||||
|
fetchAllTasks: state.fetchAllTasks,
|
||||||
|
teams: state.teams,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||||
|
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||||
|
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||||
|
initialSnapshot?.payload.projects ?? []
|
||||||
|
);
|
||||||
|
const [recentProjectsDegraded, setRecentProjectsDegraded] = useState(
|
||||||
|
initialSnapshot?.payload.degraded ?? false
|
||||||
|
);
|
||||||
|
const [degradedRefreshCount, setDegradedRefreshCount] = useState(
|
||||||
|
initialSnapshot?.payload.degraded ? 1 : 0
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(initialSnapshot == null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||||
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
|
const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
|
||||||
|
const hasFetchedTasksRef = useRef(globalTasksInitialized);
|
||||||
|
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||||
|
initialSnapshot?.payload.projects ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recentProjectsRef.current = recentProjects;
|
||||||
|
}, [recentProjects]);
|
||||||
|
|
||||||
|
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
|
||||||
|
const hasVisibleProjects =
|
||||||
|
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
|
||||||
|
|
||||||
|
if (!hasVisibleProjects) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = await loadRecentProjectsWithClientCache(
|
||||||
|
() => api.getDashboardRecentProjects(),
|
||||||
|
options
|
||||||
|
);
|
||||||
|
setRecentProjects(payload.projects);
|
||||||
|
setRecentProjectsDegraded(payload.degraded);
|
||||||
|
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const snapshot = getRecentProjectsClientSnapshot();
|
||||||
|
if (snapshot && !snapshot.isStale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reload({ force: snapshot != null });
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recentProjectsDegraded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs =
|
||||||
|
degradedRefreshCount <= DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT
|
||||||
|
? DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS
|
||||||
|
: DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void reload({ force: true });
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [degradedRefreshCount, recentProjectsDegraded, reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) {
|
||||||
|
hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFetchedTasksRef.current = true;
|
||||||
|
void fetchAllTasks();
|
||||||
|
}, [fetchAllTasks, globalTasksInitialized, recentProjects.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void api.teams
|
||||||
|
.aliveList()
|
||||||
|
.then((teamNames) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAliveTeams(teamNames);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setVisibleProjects(maxProjects);
|
||||||
|
}
|
||||||
|
}, [maxProjects, searchQuery]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => subscribeRecentProjectOpenHistory(() => setOpenHistoryVersion((current) => current + 1)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||||
|
|
||||||
|
const activeTeamsByProject = useMemo(() => {
|
||||||
|
const aliveSet = new Set(aliveTeams);
|
||||||
|
const teamsByProject = new Map<string, TeamSummary[]>();
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
if (!team.projectPath || !aliveSet.has(team.teamName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizePath(team.projectPath);
|
||||||
|
const existing = teamsByProject.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(team);
|
||||||
|
} else {
|
||||||
|
teamsByProject.set(key, [team]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamsByProject;
|
||||||
|
}, [aliveTeams, teams]);
|
||||||
|
|
||||||
|
const decoratedCards = useMemo(
|
||||||
|
() =>
|
||||||
|
adaptRecentProjectsSection({
|
||||||
|
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||||
|
taskCountsByProject,
|
||||||
|
activeTeamsByProject,
|
||||||
|
tasksLoading: globalTasksLoading,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
activeTeamsByProject,
|
||||||
|
globalTasksLoading,
|
||||||
|
openHistoryVersion,
|
||||||
|
recentProjects,
|
||||||
|
taskCountsByProject,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCards = useMemo(
|
||||||
|
() => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)),
|
||||||
|
[decoratedCards, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = useMemo(() => {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
return filteredCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredCards.slice(0, visibleProjects);
|
||||||
|
}, [filteredCards, searchQuery, visibleProjects]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
canLoadMore: !searchQuery.trim() && filteredCards.length > visibleProjects,
|
||||||
|
isElectron: isElectronMode(),
|
||||||
|
loadMore: () => setVisibleProjects((current) => current + LOAD_MORE_STEP),
|
||||||
|
reload,
|
||||||
|
openRecentProject,
|
||||||
|
openProjectPath,
|
||||||
|
selectProjectFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
2
src/features/recent-projects/renderer/index.ts
Normal file
2
src/features/recent-projects/renderer/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { RecentProjectsSection } from './ui/RecentProjectsSection';
|
||||||
|
export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory';
|
||||||
221
src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
Normal file
221
src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
|
import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
|
||||||
|
interface RecentProjectCardProps {
|
||||||
|
card: RecentProjectCardModel;
|
||||||
|
onClick: () => void;
|
||||||
|
onOpenPath: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecentProjectCard = ({
|
||||||
|
card,
|
||||||
|
onClick,
|
||||||
|
onOpenPath,
|
||||||
|
}: Readonly<RecentProjectCardProps>): React.JSX.Element => {
|
||||||
|
const color = useMemo(() => projectColor(card.name), [card.name]);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: color.border,
|
||||||
|
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||||
|
<span className="absolute right-3 top-3 inline-flex size-2.5">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 transition-colors group-hover:text-text"
|
||||||
|
style={{ color: color.icon }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||||
|
{card.name}
|
||||||
|
</h3>
|
||||||
|
{card.pathSummary && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex shrink-0 items-center rounded-full bg-surface-overlay px-1.5 py-0.5 text-[9px] font-medium text-text-muted">
|
||||||
|
{card.pathSummary.badgeLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start" className="max-w-sm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] leading-relaxed text-text-secondary">
|
||||||
|
{card.pathSummary.description}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{card.pathSummary.paths.map((pathItem) => (
|
||||||
|
<div key={`${pathItem.label}:${pathItem.fullPath}`} className="space-y-0.5">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-text-muted">
|
||||||
|
{pathItem.label}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[11px] text-text">{pathItem.fullPath}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
{card.providerIds.map((providerId) => (
|
||||||
|
<span
|
||||||
|
key={providerId}
|
||||||
|
className="bg-surface-overlay/80 inline-flex items-center rounded-full border border-border p-1"
|
||||||
|
title={providerId}
|
||||||
|
>
|
||||||
|
<ProviderBrandLogo providerId={providerId} className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full min-w-0 items-center gap-1 font-mono text-[10px] text-text-muted">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenPath();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenPath();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Open</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="truncate">{card.formattedPath}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start">
|
||||||
|
<p className="font-mono text-[11px]">{card.project.primaryPath}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card.primaryBranch ? (
|
||||||
|
<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">{card.primaryBranch}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-auto" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{card.taskCounts &&
|
||||||
|
(card.taskCounts.pending > 0 ||
|
||||||
|
card.taskCounts.inProgress > 0 ||
|
||||||
|
card.taskCounts.completed > 0) && (
|
||||||
|
<>
|
||||||
|
{card.taskCounts.inProgress > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
{card.taskCounts.inProgress} active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.taskCounts.pending > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||||
|
{card.taskCounts.pending} pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.taskCounts.completed > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
|
||||||
|
{card.taskCounts.completed} done
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-text-muted">{card.lastActivityLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{card.tasksLoading ? (
|
||||||
|
<div className="mt-2 w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
|
||||||
|
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
card.taskCounts &&
|
||||||
|
(() => {
|
||||||
|
const pending = card.taskCounts.pending ?? 0;
|
||||||
|
const inProgress = card.taskCounts.inProgress ?? 0;
|
||||||
|
const completed = card.taskCounts.completed ?? 0;
|
||||||
|
const totalTasks = pending + inProgress + completed;
|
||||||
|
if (totalTasks === 0) return null;
|
||||||
|
const progressPercent = Math.round((completed / totalTasks) * 100);
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full space-y-1">
|
||||||
|
<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: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||||
|
{completed}/{totalTasks}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
|
||||||
|
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border pt-2">
|
||||||
|
<Terminal className="size-3 shrink-0 text-emerald-400" />
|
||||||
|
{card.activeTeams.map((team) => (
|
||||||
|
<span
|
||||||
|
key={team.teamName}
|
||||||
|
className="inline-flex items-center rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"
|
||||||
|
>
|
||||||
|
{team.displayName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { FolderGit2, FolderOpen, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useRecentProjectsSection } from '../hooks/useRecentProjectsSection';
|
||||||
|
|
||||||
|
import { RecentProjectCard } from './RecentProjectCard';
|
||||||
|
|
||||||
|
interface RecentProjectsSectionProps {
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65];
|
||||||
|
const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72];
|
||||||
|
|
||||||
|
function SelectProjectFolderCard({
|
||||||
|
onClick,
|
||||||
|
}: Readonly<{
|
||||||
|
onClick: () => void;
|
||||||
|
}>): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="hover:bg-surface/30 group relative flex min-h-[120px] flex-col items-center justify-center rounded-lg border border-dashed border-border bg-transparent p-4 transition-all duration-300 hover:border-border-emphasis"
|
||||||
|
onClick={onClick}
|
||||||
|
title="Select a project folder"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex size-8 items-center justify-center rounded-md border border-dashed border-border transition-colors duration-300 group-hover:border-border-emphasis">
|
||||||
|
<FolderOpen className="size-4 text-text-muted transition-colors group-hover:text-text-secondary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted transition-colors group-hover:text-text-secondary">
|
||||||
|
Select Folder
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecentProjectsSection = ({
|
||||||
|
searchQuery,
|
||||||
|
}: Readonly<RecentProjectsSectionProps>): React.JSX.Element => {
|
||||||
|
const {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
canLoadMore,
|
||||||
|
isElectron,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
openRecentProject,
|
||||||
|
openProjectPath,
|
||||||
|
selectProjectFolder,
|
||||||
|
} = useRecentProjectsSection(searchQuery);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="skeleton-card flex min-h-[120px] flex-col rounded-sm border border-border p-4"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 80}ms`,
|
||||||
|
backgroundColor: 'var(--skeleton-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mb-3 size-8 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-light)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mb-2 h-3.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: `${titleWidths[index]}%`,
|
||||||
|
backgroundColor: 'var(--skeleton-base-light)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mb-auto h-2.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: `${pathWidths[index]}%`,
|
||||||
|
backgroundColor: 'var(--skeleton-base-dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-16 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-12 rounded-sm"
|
||||||
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<FolderGit2 className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
|
||||||
|
<p className="max-w-xl text-xs text-text-muted">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void reload()}
|
||||||
|
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0 && searchQuery.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<Search className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
||||||
|
<p className="text-xs text-text-muted">No matches for "{searchQuery}"</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
||||||
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
||||||
|
<FolderGit2 className="size-6 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-text-secondary">No recent projects found</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Recent Claude and Codex activity will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{!searchQuery.trim() && isElectron && (
|
||||||
|
<SelectProjectFolderCard onClick={() => void selectProjectFolder()} />
|
||||||
|
)}
|
||||||
|
{cards.map((card) => (
|
||||||
|
<RecentProjectCard
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
onClick={() => void openRecentProject(card.project)}
|
||||||
|
onOpenPath={() => void openProjectPath(card.project.primaryPath)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canLoadMore && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadMore}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/features/recent-projects/renderer/utils/navigation.ts
Normal file
51
src/features/recent-projects/renderer/utils/navigation.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
||||||
|
import type { RepositoryGroup } from '@renderer/types/data';
|
||||||
|
|
||||||
|
export interface WorktreeMatch {
|
||||||
|
repoId: string;
|
||||||
|
worktreeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMatchingWorktree(
|
||||||
|
groups: RepositoryGroup[],
|
||||||
|
candidatePaths: readonly string[]
|
||||||
|
): WorktreeMatch | null {
|
||||||
|
const normalizedPaths = new Set(candidatePaths.map((projectPath) => normalizePath(projectPath)));
|
||||||
|
|
||||||
|
for (const repo of groups) {
|
||||||
|
for (const worktree of repo.worktrees) {
|
||||||
|
if (normalizedPaths.has(normalizePath(worktree.path))) {
|
||||||
|
return { repoId: repo.id, worktreeId: worktree.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSyntheticRepositoryGroup(selectedPath: string): RepositoryGroup {
|
||||||
|
const encodedId = selectedPath.replace(/[/\\]/g, '-');
|
||||||
|
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: encodedId,
|
||||||
|
identity: null,
|
||||||
|
worktrees: [
|
||||||
|
{
|
||||||
|
id: encodedId,
|
||||||
|
path: selectedPath,
|
||||||
|
name: folderName,
|
||||||
|
isMainWorktree: true,
|
||||||
|
source: 'unknown',
|
||||||
|
sessions: [],
|
||||||
|
totalSessions: 0,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: folderName,
|
||||||
|
mostRecentSession: undefined,
|
||||||
|
totalSessions: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { DashboardProviderId } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
const PROVIDER_ORDER: DashboardProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||||
|
|
||||||
|
export function sortDashboardProviderIds(
|
||||||
|
providerIds: readonly DashboardProviderId[]
|
||||||
|
): DashboardProviderId[] {
|
||||||
|
return [...providerIds].sort(
|
||||||
|
(left, right) => PROVIDER_ORDER.indexOf(left) - PROVIDER_ORDER.indexOf(right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
||||||
|
const RECENT_PROJECT_OPEN_HISTORY_EVENT = 'recent-projects:open-history-changed';
|
||||||
|
const OPEN_PRIORITY_WINDOW_MS = 1000 * 60 * 60 * 48;
|
||||||
|
const MAX_HISTORY_ENTRIES = 120;
|
||||||
|
|
||||||
|
interface RecentProjectOpenHistoryEntry {
|
||||||
|
path: string;
|
||||||
|
openedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentProjectOpenHistoryState {
|
||||||
|
version: 1;
|
||||||
|
entries: RecentProjectOpenHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseLocalStorage(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHistoryPath(projectPath: string): string | null {
|
||||||
|
let normalizedPath = projectPath.trim().replace(/\\/g, '/');
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) {
|
||||||
|
while (normalizedPath.endsWith('/')) {
|
||||||
|
normalizedPath = normalizedPath.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldHistoryPath(projectPath: string): string {
|
||||||
|
return projectPath.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHistoryState(): RecentProjectOpenHistoryState {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return { version: 1, entries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return { version: 1, entries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as Partial<RecentProjectOpenHistoryState>;
|
||||||
|
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
entries: entries
|
||||||
|
.filter(
|
||||||
|
(entry): entry is RecentProjectOpenHistoryEntry =>
|
||||||
|
!!entry &&
|
||||||
|
typeof entry.path === 'string' &&
|
||||||
|
typeof entry.openedAt === 'number' &&
|
||||||
|
Number.isFinite(entry.openedAt)
|
||||||
|
)
|
||||||
|
.map((entry) => ({
|
||||||
|
path: entry.path,
|
||||||
|
openedAt: entry.openedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { version: 1, entries: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneEntries(
|
||||||
|
entries: readonly RecentProjectOpenHistoryEntry[]
|
||||||
|
): RecentProjectOpenHistoryEntry[] {
|
||||||
|
const byPath = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const normalizedPath = normalizeHistoryPath(entry.path);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byPath.set(normalizedPath, Math.max(byPath.get(normalizedPath) ?? 0, entry.openedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byPath.entries())
|
||||||
|
.map(([historyPath, openedAt]) => ({ path: historyPath, openedAt }))
|
||||||
|
.sort((left, right) => right.openedAt - left.openedAt)
|
||||||
|
.slice(0, MAX_HISTORY_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): void {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState: RecentProjectOpenHistoryState = {
|
||||||
|
version: 1,
|
||||||
|
entries: pruneEntries(entries),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(RECENT_PROJECT_OPEN_HISTORY_KEY, JSON.stringify(nextState));
|
||||||
|
window.dispatchEvent(new CustomEvent(RECENT_PROJECT_OPEN_HISTORY_EVENT));
|
||||||
|
} catch {
|
||||||
|
// Best-effort persistence only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryLookup {
|
||||||
|
exact: Map<string, number>;
|
||||||
|
folded: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
openedAt: number;
|
||||||
|
exactPaths: Set<string>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHistoryLookup(): HistoryLookup {
|
||||||
|
const exact = new Map<string, number>();
|
||||||
|
const folded = new Map<string, { openedAt: number; exactPaths: Set<string> }>();
|
||||||
|
|
||||||
|
for (const entry of readHistoryState().entries) {
|
||||||
|
const normalizedPath = normalizeHistoryPath(entry.path);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt));
|
||||||
|
|
||||||
|
const foldedKey = foldHistoryPath(normalizedPath);
|
||||||
|
const existingFolded = folded.get(foldedKey);
|
||||||
|
if (existingFolded) {
|
||||||
|
existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt);
|
||||||
|
existingFolded.exactPaths.add(normalizedPath);
|
||||||
|
} else {
|
||||||
|
folded.set(foldedKey, {
|
||||||
|
openedAt: entry.openedAt,
|
||||||
|
exactPaths: new Set([normalizedPath]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exact, folded };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number {
|
||||||
|
const normalizedPath = normalizeHistoryPath(projectPath);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = lookup.exact.get(normalizedPath);
|
||||||
|
if (exactMatch != null) {
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||||
|
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return foldedMatch.openedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectLastOpenedAtFromLookup(
|
||||||
|
lookup: HistoryLookup,
|
||||||
|
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||||
|
): number {
|
||||||
|
return [project.primaryPath, ...project.associatedPaths].reduce(
|
||||||
|
(latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecentProjectOpenPaths(
|
||||||
|
projectPaths: readonly string[],
|
||||||
|
openedAt: number = Date.now()
|
||||||
|
): void {
|
||||||
|
const normalizedPaths = Array.from(
|
||||||
|
new Set(
|
||||||
|
projectPaths
|
||||||
|
.map((projectPath) => normalizeHistoryPath(projectPath))
|
||||||
|
.filter((projectPath): projectPath is string => Boolean(projectPath))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedPaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readHistoryState().entries;
|
||||||
|
writeHistoryEntries([
|
||||||
|
...existing,
|
||||||
|
...normalizedPaths.map((projectPath) => ({
|
||||||
|
path: projectPath,
|
||||||
|
openedAt,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentProjectLastOpenedAt(
|
||||||
|
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||||
|
): number {
|
||||||
|
const historyLookup = createHistoryLookup();
|
||||||
|
return getProjectLastOpenedAtFromLookup(historyLookup, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortRecentProjectsByDisplayPriority(
|
||||||
|
projects: readonly DashboardRecentProject[],
|
||||||
|
now: number = Date.now()
|
||||||
|
): DashboardRecentProject[] {
|
||||||
|
const historyLookup = createHistoryLookup();
|
||||||
|
|
||||||
|
const isPriorityOpen = (openedAt: number): boolean =>
|
||||||
|
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
|
||||||
|
|
||||||
|
return [...projects].sort((left, right) => {
|
||||||
|
const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left);
|
||||||
|
const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right);
|
||||||
|
const leftPriority = isPriorityOpen(leftOpenedAt);
|
||||||
|
const rightPriority = isPriorityOpen(rightOpenedAt);
|
||||||
|
|
||||||
|
if (leftPriority !== rightPriority) {
|
||||||
|
return leftPriority ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftPriority && rightPriority && leftOpenedAt !== rightOpenedAt) {
|
||||||
|
return rightOpenedAt - leftOpenedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.mostRecentActivity !== right.mostRecentActivity) {
|
||||||
|
return right.mostRecentActivity - left.mostRecentActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftOpenedAt !== rightOpenedAt) {
|
||||||
|
return rightOpenedAt - leftOpenedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.name.localeCompare(right.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRecentProjectOpenHistory(listener: () => void): () => void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (): void => listener();
|
||||||
|
window.addEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRecentProjectOpenHistoryForTests(): void {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { normalizeDashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DashboardRecentProjectsPayload,
|
||||||
|
DashboardRecentProjectsPayloadLike,
|
||||||
|
} from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
|
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
||||||
|
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||||
|
|
||||||
|
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null;
|
||||||
|
|
||||||
|
export interface RecentProjectsClientSnapshot {
|
||||||
|
payload: DashboardRecentProjectsPayload;
|
||||||
|
fetchedAt: number;
|
||||||
|
isStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
|
||||||
|
const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
|
||||||
|
if (!normalizedPayload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedPayload !== normalizedPayload) {
|
||||||
|
cachedPayload = normalizedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = normalizedPayload.degraded
|
||||||
|
? RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS
|
||||||
|
: RECENT_PROJECTS_CLIENT_CACHE_TTL_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: normalizedPayload,
|
||||||
|
fetchedAt: cachedAt,
|
||||||
|
isStale: Date.now() - cachedAt > ttlMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRecentProjectsWithClientCache(
|
||||||
|
loader: () => Promise<DashboardRecentProjectsPayloadLike>,
|
||||||
|
options?: { force?: boolean }
|
||||||
|
): Promise<DashboardRecentProjectsPayload> {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const snapshot = getRecentProjectsClientSnapshot();
|
||||||
|
|
||||||
|
if (!force && snapshot && !snapshot.isStale) {
|
||||||
|
return snapshot.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFlightLoad) {
|
||||||
|
return inFlightLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = loader()
|
||||||
|
.then((payloadLike) => {
|
||||||
|
const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
|
||||||
|
cachedPayload = normalizedPayload;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return normalizedPayload ?? { projects: [], degraded: true };
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlightLoad === request) {
|
||||||
|
inFlightLoad = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightLoad = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetRecentProjectsClientCacheForTests(): void {
|
||||||
|
cachedPayload = null;
|
||||||
|
cachedAt = 0;
|
||||||
|
inFlightLoad = null;
|
||||||
|
}
|
||||||
11
src/features/tmux-installer/contracts/api.ts
Normal file
11
src/features/tmux-installer/contracts/api.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { TmuxInstallerProgress, TmuxInstallerSnapshot, TmuxStatus } from './dto';
|
||||||
|
|
||||||
|
export interface TmuxAPI {
|
||||||
|
getStatus: () => Promise<TmuxStatus>;
|
||||||
|
getInstallerSnapshot: () => Promise<TmuxInstallerSnapshot>;
|
||||||
|
install: () => Promise<void>;
|
||||||
|
cancelInstall: () => Promise<void>;
|
||||||
|
submitInstallerInput: (input: string) => Promise<void>;
|
||||||
|
invalidateStatus: () => Promise<void>;
|
||||||
|
onProgress: (callback: (event: unknown, data: TmuxInstallerProgress) => void) => () => void;
|
||||||
|
}
|
||||||
7
src/features/tmux-installer/contracts/channels.ts
Normal file
7
src/features/tmux-installer/contracts/channels.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const TMUX_GET_STATUS = 'tmux:getStatus';
|
||||||
|
export const TMUX_GET_INSTALLER_SNAPSHOT = 'tmux:getInstallerSnapshot';
|
||||||
|
export const TMUX_INSTALL = 'tmux:install';
|
||||||
|
export const TMUX_CANCEL_INSTALL = 'tmux:cancelInstall';
|
||||||
|
export const TMUX_SUBMIT_INSTALLER_INPUT = 'tmux:submitInstallerInput';
|
||||||
|
export const TMUX_INVALIDATE_STATUS = 'tmux:invalidateStatus';
|
||||||
|
export const TMUX_INSTALLER_PROGRESS = 'tmux:progress';
|
||||||
109
src/features/tmux-installer/contracts/dto.ts
Normal file
109
src/features/tmux-installer/contracts/dto.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown';
|
||||||
|
|
||||||
|
export type TmuxInstallStrategy =
|
||||||
|
| 'homebrew'
|
||||||
|
| 'macports'
|
||||||
|
| 'apt'
|
||||||
|
| 'dnf'
|
||||||
|
| 'yum'
|
||||||
|
| 'zypper'
|
||||||
|
| 'pacman'
|
||||||
|
| 'wsl'
|
||||||
|
| 'manual'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type TmuxInstallerPhase =
|
||||||
|
| 'idle'
|
||||||
|
| 'checking'
|
||||||
|
| 'preparing'
|
||||||
|
| 'requesting_privileges'
|
||||||
|
| 'pending_external_elevation'
|
||||||
|
| 'waiting_for_external_step'
|
||||||
|
| 'installing'
|
||||||
|
| 'verifying'
|
||||||
|
| 'needs_restart'
|
||||||
|
| 'needs_manual_step'
|
||||||
|
| 'completed'
|
||||||
|
| 'error'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export interface TmuxInstallHint {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
command?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxAutoInstallCapability {
|
||||||
|
supported: boolean;
|
||||||
|
strategy: TmuxInstallStrategy;
|
||||||
|
packageManagerLabel?: string | null;
|
||||||
|
requiresTerminalInput: boolean;
|
||||||
|
requiresAdmin: boolean;
|
||||||
|
requiresRestart: boolean;
|
||||||
|
mayOpenExternalWindow?: boolean;
|
||||||
|
reasonIfUnsupported?: string | null;
|
||||||
|
manualHints: TmuxInstallHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxWslStatus {
|
||||||
|
wslInstalled: boolean;
|
||||||
|
rebootRequired: boolean;
|
||||||
|
distroName: string | null;
|
||||||
|
distroVersion: 1 | 2 | null;
|
||||||
|
distroBootstrapped: boolean;
|
||||||
|
innerPackageManager: TmuxInstallStrategy | null;
|
||||||
|
tmuxAvailableInsideWsl: boolean;
|
||||||
|
tmuxVersion: string | null;
|
||||||
|
tmuxBinaryPath: string | null;
|
||||||
|
statusDetail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxWslPreference {
|
||||||
|
preferredDistroName: string | null;
|
||||||
|
source: 'persisted' | 'default' | 'manual' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxBinaryProbe {
|
||||||
|
available: boolean;
|
||||||
|
version: string | null;
|
||||||
|
binaryPath: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxEffectiveAvailability {
|
||||||
|
available: boolean;
|
||||||
|
location: 'host' | 'wsl' | null;
|
||||||
|
version: string | null;
|
||||||
|
binaryPath: string | null;
|
||||||
|
runtimeReady: boolean;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxStatus {
|
||||||
|
platform: TmuxPlatform;
|
||||||
|
nativeSupported: boolean;
|
||||||
|
checkedAt: string;
|
||||||
|
host: TmuxBinaryProbe;
|
||||||
|
effective: TmuxEffectiveAvailability;
|
||||||
|
error: string | null;
|
||||||
|
autoInstall: TmuxAutoInstallCapability;
|
||||||
|
wsl?: TmuxWslStatus | null;
|
||||||
|
wslPreference?: TmuxWslPreference | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxInstallerSnapshot {
|
||||||
|
phase: TmuxInstallerPhase;
|
||||||
|
strategy: TmuxInstallStrategy | null;
|
||||||
|
message: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
error: string | null;
|
||||||
|
canCancel: boolean;
|
||||||
|
acceptsInput: boolean;
|
||||||
|
inputPrompt: string | null;
|
||||||
|
inputSecret: boolean;
|
||||||
|
logs: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TmuxInstallerProgress = TmuxInstallerSnapshot;
|
||||||
3
src/features/tmux-installer/contracts/index.ts
Normal file
3
src/features/tmux-installer/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type * from './api';
|
||||||
|
export * from './channels';
|
||||||
|
export type * from './dto';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface TmuxInstallerRunnerPort {
|
||||||
|
install(): Promise<void>;
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
submitInput(input: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts';
|
||||||
|
|
||||||
|
export interface TmuxInstallerSnapshotPort {
|
||||||
|
getSnapshot(): TmuxInstallerSnapshot;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { TmuxStatus } from '@features/tmux-installer/contracts';
|
||||||
|
|
||||||
|
export interface TmuxStatusSourcePort {
|
||||||
|
getStatus(): Promise<TmuxStatus>;
|
||||||
|
invalidateStatus(): void;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue