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
|
||||
|
||||
- All IPC handlers validate inputs with strict path containment checks
|
||||
- File reads are constrained to the project root and `~/.claude/`
|
||||
- Electron IPC and standalone HTTP handlers validate IDs, paths, and payloads at the boundary
|
||||
- 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
|
||||
- Sensitive credential paths are rejected
|
||||
- Sensitive config and credential-like paths are rejected or treated as protected targets
|
||||
|
||||
## 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/*`
|
||||
|
||||
## Features Architecture
|
||||
**All new features MUST be created in `src/renderer/features/<feature-name>/`.**
|
||||
See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns.
|
||||
**All new medium and large features should follow the canonical slice standard in [`docs/FEATURE_ARCHITECTURE_STANDARD.md`](docs/FEATURE_ARCHITECTURE_STANDARD.md).**
|
||||
|
||||
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
|
||||
~/.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>
|
||||
</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">
|
||||
<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 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>
|
||||
|
||||
<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)
|
||||
- [Table of contents](#table-of-contents)
|
||||
- [What is this](#what-is-this)
|
||||
- [Developer architecture docs](#developer-architecture-docs)
|
||||
- [Comparison](#comparison)
|
||||
- [Quick start](#quick-start)
|
||||
- [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.
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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 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 (tools), actions 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
|
||||
- **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
|
||||
|
|
@ -156,9 +156,18 @@ A local orchestration layer for AI agent teams across Claude and Codex.
|
|||
|
||||
</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
|
||||
|
||||
| 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** | ✅ | ❌ | ❌ | — | ❌ |
|
||||
| **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
|
||||
- [ ] 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)
|
||||
- [ ] 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)
|
||||
|
|
@ -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
|
||||
- [ ] Custom kanban columns
|
||||
- [ ] 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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
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: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@preload': resolve(__dirname, 'src/preload')
|
||||
|
|
@ -111,6 +112,7 @@ export default defineConfig({
|
|||
preload: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@preload': resolve(__dirname, 'src/preload'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main')
|
||||
|
|
@ -141,6 +143,7 @@ export default defineConfig({
|
|||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(__dirname, 'src/features'),
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@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)
|
||||
{
|
||||
name: 'import-plugin-renderer',
|
||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
||||
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
|
|
@ -96,6 +96,255 @@ export default defineConfig([
|
|||
'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
|
||||
{
|
||||
|
|
@ -548,7 +797,7 @@ export default defineConfig([
|
|||
|
||||
// === Import Restrictions ===
|
||||
// Note: boundaries/element-types handles main/renderer separation
|
||||
'no-restricted-imports': 'warn',
|
||||
'no-restricted-imports': 'off',
|
||||
|
||||
// === Mutation Prevention ===
|
||||
'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 ===
|
||||
// This disables all ESLint rules that conflict with Prettier
|
||||
// Prettier handles formatting, ESLint handles code quality
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"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",
|
||||
"build": "electron-vite build",
|
||||
|
|
@ -321,6 +322,9 @@
|
|||
"tsconfig*.json"
|
||||
],
|
||||
"paths": {
|
||||
"@features/*": [
|
||||
"./src/features/*"
|
||||
],
|
||||
"@main/*": [
|
||||
"./src/main/*"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function drawAgents(
|
|||
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +122,17 @@ export function drawAgents(
|
|||
if (!simplify) {
|
||||
// Name + role label (single line: "jack · developer")
|
||||
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
|
||||
|
|
@ -256,52 +266,87 @@ function drawLaunchStage(
|
|||
ctx.save();
|
||||
switch (visualState) {
|
||||
case 'waiting': {
|
||||
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
|
||||
const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
|
||||
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||
const dotOrbit = r + 11;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([4, 5]);
|
||||
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;
|
||||
}
|
||||
case 'spawning': {
|
||||
const ringR = r + 7;
|
||||
const rotation = time * 2.4;
|
||||
const rotation = time * 2.7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
|
||||
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8);
|
||||
ctx.lineWidth = 2.8;
|
||||
ctx.lineCap = 'round';
|
||||
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;
|
||||
}
|
||||
case 'runtime_pending': {
|
||||
const ringR = r + 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48);
|
||||
ctx.lineWidth = 1.9;
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
const orbit = time * 1.6;
|
||||
const dotR = 2.2;
|
||||
const dotX = x + Math.cos(orbit) * ringR;
|
||||
const dotY = y + Math.sin(orbit) * ringR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9);
|
||||
ctx.fill();
|
||||
const orbit = time * 1.8;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const angle = orbit + Math.PI * index;
|
||||
const dotX = x + Math.cos(angle) * ringR;
|
||||
const dotY = y + Math.sin(angle) * ringR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92);
|
||||
ctx.fill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'settling': {
|
||||
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;
|
||||
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.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
|
||||
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55);
|
||||
ctx.lineWidth = 1.75;
|
||||
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62);
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
break;
|
||||
|
|
@ -310,10 +355,15 @@ function drawLaunchStage(
|
|||
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
|
||||
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72);
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.lineCap = 'round';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -467,12 +517,8 @@ function drawBreathing(
|
|||
r: number,
|
||||
state: string,
|
||||
time: number,
|
||||
spawnStatus?: GraphNode['spawnStatus'],
|
||||
runtimeLabel?: string
|
||||
spawnStatus?: GraphNode['spawnStatus']
|
||||
): void {
|
||||
const hasRuntimeLabel = Boolean(runtimeLabel?.trim());
|
||||
const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14);
|
||||
|
||||
// Spawning: bright animated double ring + radial glow
|
||||
if (spawnStatus === 'spawning') {
|
||||
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
||||
|
|
@ -505,12 +551,6 @@ function drawBreathing(
|
|||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -532,12 +572,6 @@ function drawBreathing(
|
|||
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
|
||||
ctx.lineWidth = 1.5;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -649,7 +683,9 @@ function drawLabel(
|
|||
r: number,
|
||||
label: string,
|
||||
color: string,
|
||||
runtimeLabel?: string
|
||||
runtimeLabel?: string,
|
||||
launchStatusLabel?: string,
|
||||
launchVisualState?: GraphNode['launchVisualState']
|
||||
): void {
|
||||
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
||||
ctx.font = '9px monospace';
|
||||
|
|
@ -659,16 +695,27 @@ function drawLabel(
|
|||
ctx.fillText(label, x, labelY);
|
||||
|
||||
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
||||
if (!trimmedRuntimeLabel) {
|
||||
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
|
||||
if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.font = '8px monospace';
|
||||
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
||||
ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10);
|
||||
let nextLineY = labelY + 10;
|
||||
if (trimmedRuntimeLabel) {
|
||||
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);
|
||||
if (ctx.measureText(label).width <= maxWidth) return label;
|
||||
|
||||
|
|
@ -679,6 +726,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n
|
|||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { STABLE_SLOT_GEOMETRY } from '../layout/stableSlotGeometry';
|
||||
|
||||
/**
|
||||
* Canvas rendering constants for the agent graph visualization.
|
||||
* Adapted from agent-flow's canvas-constants.ts (Apache 2.0).
|
||||
|
|
@ -260,10 +262,12 @@ export const KANBAN_ZONE = {
|
|||
columnWidth: 180,
|
||||
/** Row height: pill (36) + gap (10) */
|
||||
rowHeight: 46,
|
||||
/** Space reserved for column header label */
|
||||
headerHeight: 20,
|
||||
/** Zone starts this far below member node center */
|
||||
offsetY: 70,
|
||||
/** Column order: todo → wip → done → review → approved */
|
||||
/** Column sequence: pending → wip → done → review → approved */
|
||||
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
|
||||
/** Max tasks shown per column (overflow hidden) */
|
||||
maxVisibleRows: 6,
|
||||
maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ export function useGraphInteraction(
|
|||
clickedNodeId.current = 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);
|
||||
if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) {
|
||||
if (hitNode?.kind === 'member') {
|
||||
dragNodeId.current = hit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
|||
// ─── Components ──────────────────────────────────────────────────────────────
|
||||
export { GraphView } 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) ─────────────────────────
|
||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||
|
|
@ -21,6 +22,9 @@ export type {
|
|||
GraphEdge,
|
||||
GraphParticle,
|
||||
GraphActivityItem,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphLayoutPort,
|
||||
GraphLayoutVersion,
|
||||
GraphNodeKind,
|
||||
GraphNodeState,
|
||||
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 { createStableSlotActivityLane } from './stableSlotGeometry';
|
||||
|
||||
export const ACTIVITY_LANE = {
|
||||
width: 296,
|
||||
itemHeight: 58,
|
||||
rowHeight: 62,
|
||||
maxVisibleItems: 3,
|
||||
headerHeight: 18,
|
||||
overflowHeight: 18,
|
||||
horizontalGapLead: 76,
|
||||
horizontalGapMember: 84,
|
||||
bottomClearance: 18,
|
||||
viewportPadding: 12,
|
||||
visiblePadding: 80,
|
||||
minScale: 0,
|
||||
maxScale: 1,
|
||||
} as const;
|
||||
const STABLE_SLOT_ACTIVITY = createStableSlotActivityLane({
|
||||
nodeMetrics: {
|
||||
radiusLead: NODE.radiusLead,
|
||||
radiusMember: NODE.radiusMember,
|
||||
},
|
||||
zoomRange: {
|
||||
minZoom: CAMERA.minZoom,
|
||||
maxZoom: CAMERA.maxZoom,
|
||||
},
|
||||
});
|
||||
|
||||
const RESERVED_HEIGHT =
|
||||
ACTIVITY_LANE.headerHeight
|
||||
+ 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 const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane;
|
||||
export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor;
|
||||
|
||||
export interface ActivityLaneWindow {
|
||||
items: GraphActivityItem[];
|
||||
|
|
@ -51,6 +35,32 @@ export interface ActivityLaneItemHit {
|
|||
|
||||
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: {
|
||||
nodeKind: 'lead' | 'member';
|
||||
nodeX: number;
|
||||
|
|
@ -72,18 +82,14 @@ export function getActivityAnchorTarget(args: {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const { nodeX, nodeY, nodeKind } = args;
|
||||
return {
|
||||
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX,
|
||||
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY,
|
||||
x: nodeX - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2,
|
||||
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;
|
||||
bottom: number;
|
||||
} {
|
||||
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,
|
||||
left: anchorX,
|
||||
top: anchorY,
|
||||
right: anchorX + ACTIVITY_ANCHOR_LAYOUT.reservedWidth,
|
||||
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 {
|
||||
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 scaledWidth = ACTIVITY_LANE.width * scale;
|
||||
const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
|
||||
const screenX = anchorX * zoom + cameraX;
|
||||
const screenY = anchorY * zoom + cameraY;
|
||||
const x = screenX - scaledWidth / 2;
|
||||
const y = screenY - scaledHeight / 2;
|
||||
const x = anchorX * zoom + cameraX;
|
||||
const y = anchorY * zoom + cameraY;
|
||||
const right = x + scaledWidth;
|
||||
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(
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
|
|
@ -177,13 +250,17 @@ export function findActivityItemAt(
|
|||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
|
||||
const item = items.at(index);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
worldX >= left &&
|
||||
worldX <= left + ACTIVITY_LANE.width &&
|
||||
worldY >= itemTop &&
|
||||
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' } {
|
||||
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 { KANBAN_ZONE } from '../constants/canvas-constants';
|
||||
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||
import { COLORS } from '../constants/colors';
|
||||
import { resolveActivityLaneSide } from './activityLane';
|
||||
import type { SlotFrame, StableRect } from './stableSlots';
|
||||
|
||||
/** Column header info for rendering */
|
||||
export interface KanbanColumnHeader {
|
||||
|
|
@ -48,7 +48,7 @@ export function getOwnerKanbanBaseX(args: {
|
|||
columnWidth: number;
|
||||
leadX?: number | null;
|
||||
}): number {
|
||||
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
|
||||
const { ownerX, ownerKind, activeColumnCount, columnWidth } = args;
|
||||
if (activeColumnCount <= 0) {
|
||||
return ownerX;
|
||||
}
|
||||
|
|
@ -57,17 +57,7 @@ export function getOwnerKanbanBaseX(args: {
|
|||
return ownerX - (activeColumnCount * columnWidth) / 2;
|
||||
}
|
||||
|
||||
const side = resolveActivityLaneSide({
|
||||
nodeKind: ownerKind,
|
||||
nodeX: ownerX,
|
||||
leadX,
|
||||
});
|
||||
|
||||
if (side === 'left') {
|
||||
return ownerX;
|
||||
}
|
||||
|
||||
return ownerX - (activeColumnCount - 1) * columnWidth;
|
||||
return ownerX - ((activeColumnCount - 1) * columnWidth) / 2;
|
||||
}
|
||||
|
||||
export class KanbanLayoutEngine {
|
||||
|
|
@ -78,26 +68,52 @@ export class KanbanLayoutEngine {
|
|||
static readonly #colTasks = new Map<string, GraphNode[]>();
|
||||
|
||||
/** 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.
|
||||
* 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;
|
||||
nodeMap.clear();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
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;
|
||||
tasksByOwner.clear();
|
||||
const unassigned = this.#unassigned;
|
||||
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) {
|
||||
if (n.kind !== 'task') continue;
|
||||
if (n.ownerId) {
|
||||
if (n.ownerId && hasLayoutOwner(n.ownerId)) {
|
||||
let group = tasksByOwner.get(n.ownerId);
|
||||
if (!group) {
|
||||
group = [];
|
||||
|
|
@ -110,16 +126,22 @@ export class KanbanLayoutEngine {
|
|||
}
|
||||
|
||||
// Reset zones
|
||||
this.zones = [];
|
||||
this.zones.length = 0;
|
||||
|
||||
for (const [ownerId, tasks] of tasksByOwner) {
|
||||
const owner = nodeMap.get(ownerId);
|
||||
if (!owner || owner.x == null || owner.y == null) continue;
|
||||
const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX);
|
||||
if (owner?.x == null || owner?.y == null) continue;
|
||||
const zoneInfo = KanbanLayoutEngine.#layoutZone(
|
||||
tasks,
|
||||
owner,
|
||||
ownerId,
|
||||
leadX,
|
||||
ownerSlotFrameByOwnerId.get(ownerId) ?? null
|
||||
);
|
||||
if (zoneInfo) this.zones.push(zoneInfo);
|
||||
}
|
||||
|
||||
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes);
|
||||
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null);
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -128,13 +150,12 @@ export class KanbanLayoutEngine {
|
|||
tasks: GraphNode[],
|
||||
owner: GraphNode,
|
||||
ownerId: string,
|
||||
leadX: number | null
|
||||
leadX: number | null,
|
||||
slotFrame: SlotFrame | null
|
||||
): KanbanZoneInfo | null {
|
||||
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
|
||||
const headerHeight = 20; // space for column header label
|
||||
const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE;
|
||||
const ownerX = owner.x ?? 0;
|
||||
const ownerY = owner.y ?? 0;
|
||||
const baseY = ownerY + offsetY;
|
||||
|
||||
// Classify tasks into columns
|
||||
const colTasks = KanbanLayoutEngine.#colTasks;
|
||||
|
|
@ -157,15 +178,21 @@ export class KanbanLayoutEngine {
|
|||
|
||||
if (activeColumns.length === 0) return null;
|
||||
|
||||
// Keep kanban columns on the open side of the owner, away from the reserved activity lane.
|
||||
// This makes member lanes reserve real visual space instead of only affecting the force layout.
|
||||
const baseX = getOwnerKanbanBaseX({
|
||||
let baseX = getOwnerKanbanBaseX({
|
||||
ownerX,
|
||||
ownerKind: owner.kind,
|
||||
activeColumnCount: activeColumns.length,
|
||||
columnWidth,
|
||||
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
|
||||
const headers: KanbanColumnHeader[] = [];
|
||||
|
|
@ -190,8 +217,8 @@ export class KanbanLayoutEngine {
|
|||
for (const [rowIdx, task] of col.tasks.entries()) {
|
||||
const targetX = colX;
|
||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||
task.x = 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.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
||||
task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||
task.fx = task.x;
|
||||
task.fy = task.y;
|
||||
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;
|
||||
|
||||
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)
|
||||
let sumX = 0;
|
||||
let maxY = -Infinity;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { NODE } from '../constants/canvas-constants';
|
||||
import {
|
||||
ACTIVITY_ANCHOR_LAYOUT,
|
||||
getActivityAnchorTarget,
|
||||
getActivityLaneBounds,
|
||||
resolveActivityLaneSide,
|
||||
} from './activityLane';
|
||||
import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry';
|
||||
|
||||
export interface WorldBounds {
|
||||
left: number;
|
||||
|
|
@ -19,17 +19,9 @@ export interface LaunchAnchorScreenPlacement {
|
|||
visible: boolean;
|
||||
}
|
||||
|
||||
export const LAUNCH_ANCHOR_LAYOUT = {
|
||||
compactWidth: 336,
|
||||
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;
|
||||
export const LAUNCH_ANCHOR_LAYOUT = createStableSlotLaunchAnchorLayout({
|
||||
radiusLead: NODE.radiusLead,
|
||||
});
|
||||
|
||||
const LAUNCH_ANCHOR_PREFIX = '__launch_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 getHandoffAnchorId = getActivityAnchorId;
|
||||
export const isHandoffAnchorId = isActivityAnchorId;
|
||||
export { getActivityAnchorTarget };
|
||||
export const getHandoffAnchorTarget = getActivityAnchorTarget;
|
||||
export const getHandoffAnchorBounds = getActivityLaneBounds;
|
||||
|
||||
export function getHandoffAnchorTarget(args: {
|
||||
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: {
|
||||
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.
|
||||
|
|
@ -17,4 +17,6 @@ export interface GraphDataPort {
|
|||
teamColor?: string;
|
||||
/** Whether the team lead process is alive */
|
||||
isAlive?: boolean;
|
||||
/** Stable owner-slot layout hints supplied by the host app */
|
||||
layout?: GraphLayoutPort;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ export type {
|
|||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphParticle,
|
||||
GraphActivityItem,
|
||||
GraphNodeKind,
|
||||
GraphNodeState,
|
||||
GraphEdgeType,
|
||||
GraphParticleKind,
|
||||
GraphDomainRef,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphLayoutPort,
|
||||
GraphLayoutVersion,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -48,6 +48,19 @@ export interface GraphActivityItem {
|
|||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GraphNode {
|
||||
|
|
@ -71,6 +84,8 @@ export interface GraphNode {
|
|||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
/** Shared launch-stage visual derived by the host app */
|
||||
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 */
|
||||
contextUsage?: number;
|
||||
/** 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 { drawProcesses } from '../canvas/draw-processes';
|
||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||
import { drawHexagon } from '../canvas/draw-misc';
|
||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||
import {
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
updateTransientHandoffState,
|
||||
} from './transientHandoffs';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
import { NODE } from '../constants/canvas-constants';
|
||||
|
||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||
|
||||
|
|
@ -53,6 +55,14 @@ export interface GraphDrawState {
|
|||
hoveredEdgeId: string | null;
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
dragPreview:
|
||||
| {
|
||||
nodeId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color?: string | null;
|
||||
}
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
|
|
@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
state.focusNodeIds,
|
||||
zoom
|
||||
);
|
||||
if (state.dragPreview) {
|
||||
drawOwnerSlotPreview(ctx, state.dragPreview, state.time);
|
||||
}
|
||||
|
||||
// 2d. Effects
|
||||
drawEffects(ctx, state.effects);
|
||||
|
|
@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
</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,
|
||||
Maximize2,
|
||||
Pause,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
Play,
|
||||
Plus,
|
||||
|
|
@ -41,9 +43,13 @@ export interface GraphControlsProps {
|
|||
onRequestFullscreen?: () => void;
|
||||
onOpenTeamPage?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
isSidebarVisible?: boolean;
|
||||
teamName: string;
|
||||
teamColor?: string;
|
||||
isAlive?: boolean;
|
||||
topToolbarContent?: React.ReactNode;
|
||||
interactionLocked?: boolean;
|
||||
}
|
||||
|
||||
const TOPBAR_BUTTON_SIZE = 25;
|
||||
|
|
@ -60,7 +66,11 @@ export function GraphControls({
|
|||
onRequestFullscreen,
|
||||
onOpenTeamPage,
|
||||
onCreateTask,
|
||||
onToggleSidebar,
|
||||
isSidebarVisible = true,
|
||||
teamColor,
|
||||
topToolbarContent,
|
||||
interactionLocked = false,
|
||||
}: GraphControlsProps): React.JSX.Element {
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const settingsRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -96,141 +106,176 @@ export function GraphControls({
|
|||
}, [isSettingsOpen]);
|
||||
|
||||
const nameColor = teamColor ?? '#aaeeff';
|
||||
const chromeInteractivityClass = interactionLocked
|
||||
? 'pointer-events-none select-none'
|
||||
: 'pointer-events-auto';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
||||
{onOpenTeamPage ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onOpenTeamPage}
|
||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Open team page"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onCreateTask ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onCreateTask}
|
||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Create task"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={() => toggle('paused')}
|
||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 h-8">
|
||||
<div className="absolute left-0 top-0 flex shrink-0 items-center gap-0.5">
|
||||
{onToggleSidebar ? (
|
||||
<div
|
||||
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onToggleSidebar}
|
||||
icon={
|
||||
isSidebarVisible ? (
|
||||
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
||||
) : (
|
||||
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
||||
)
|
||||
}
|
||||
toolbar
|
||||
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onOpenTeamPage ? (
|
||||
<div
|
||||
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onOpenTeamPage}
|
||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Open team page"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{onCreateTask ? (
|
||||
<div
|
||||
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={onCreateTask}
|
||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Create task"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</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
|
||||
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={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||
active={isSettingsOpen}
|
||||
onClick={() => toggle('paused')}
|
||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||
toolbar
|
||||
title="Graph settings"
|
||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSettingsOpen && (
|
||||
<div ref={settingsRef} className={`relative ${chromeInteractivityClass}`}>
|
||||
<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={{
|
||||
background: 'rgba(8, 12, 24, 0.96)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.12)',
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<ToolbarToggle
|
||||
active={filters.showTasks}
|
||||
onClick={() => toggle('showTasks')}
|
||||
icon={<Columns3 size={13} />}
|
||||
label="Tasks"
|
||||
block
|
||||
/>
|
||||
<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
|
||||
<ToolbarButton
|
||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||
active={isSettingsOpen}
|
||||
toolbar
|
||||
title="Graph settings"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto 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"
|
||||
/>
|
||||
)}
|
||||
{isSettingsOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.96)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.12)',
|
||||
}}
|
||||
>
|
||||
<ToolbarToggle
|
||||
active={filters.showTasks}
|
||||
onClick={() => toggle('showTasks')}
|
||||
icon={<Columns3 size={13} />}
|
||||
label="Tasks"
|
||||
block
|
||||
/>
|
||||
<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
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
|
|||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||
import type { GraphEdge, GraphNode } from '../ports/types';
|
||||
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
|
||||
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||
import type { StableRect } from '../layout/stableSlots';
|
||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||
import { GraphOverlay } from './GraphOverlay';
|
||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||
|
|
@ -31,7 +32,6 @@ import {
|
|||
getEdgeMidpoint,
|
||||
} from '../canvas/hit-detection';
|
||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane';
|
||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||
|
||||
export interface GraphViewProps {
|
||||
|
|
@ -43,8 +43,18 @@ export interface GraphViewProps {
|
|||
onRequestClose?: () => void;
|
||||
onRequestPinAsTab?: () => void;
|
||||
onRequestFullscreen?: () => void;
|
||||
isSurfaceActive?: boolean;
|
||||
onOpenTeamPage?: () => 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. */
|
||||
renderOverlay?: (props: {
|
||||
node: GraphNode;
|
||||
|
|
@ -62,12 +72,11 @@ export interface GraphViewProps {
|
|||
getLaunchAnchorScreenPlacement: (
|
||||
leadNodeId: string,
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityAnchorScreenPlacement: (
|
||||
ownerNodeId: string,
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getNodeScreenPosition: (
|
||||
nodeId: string,
|
||||
) => { x: number; y: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | 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;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
|
@ -81,8 +90,13 @@ export function GraphView({
|
|||
onRequestClose,
|
||||
onRequestPinAsTab,
|
||||
onRequestFullscreen,
|
||||
isSurfaceActive = true,
|
||||
onOpenTeamPage,
|
||||
onCreateTask,
|
||||
onToggleSidebar,
|
||||
isSidebarVisible = true,
|
||||
renderTopToolbarContent,
|
||||
onOwnerSlotDrop,
|
||||
renderOverlay,
|
||||
renderEdgeOverlay,
|
||||
renderHud,
|
||||
|
|
@ -90,6 +104,7 @@ export function GraphView({
|
|||
// ─── React state (user-facing only) ─────────────────────────────────────
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [interactionLocked, setInteractionLocked] = useState(false);
|
||||
const [filters, setFilters] = useState<GraphFilterState>({
|
||||
showTasks: config?.showTasks ?? true,
|
||||
showProcesses: config?.showProcesses ?? true,
|
||||
|
|
@ -115,6 +130,14 @@ export function GraphView({
|
|||
const allowAutoFitRef = useRef(true);
|
||||
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
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 ────────────────────────────────
|
||||
useEffect(() => {
|
||||
const filteredNodes = data.nodes.filter((n) => {
|
||||
if (n.kind === 'task' && !filters.showTasks) return false;
|
||||
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]);
|
||||
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
|
||||
}, [data, simulation]);
|
||||
|
||||
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
||||
const focusState = useMemo(
|
||||
|
|
@ -209,44 +245,51 @@ export function GraphView({
|
|||
viewportHeight: viewport.height,
|
||||
});
|
||||
}, [getViewportSize]);
|
||||
const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => {
|
||||
const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId);
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
const viewport = getViewportSize();
|
||||
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 getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
||||
const getActivityWorldRect = useCallback(
|
||||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||
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;
|
||||
}
|
||||
const transform = cameraRef.current.transformRef.current;
|
||||
const x = node.x * transform.zoom + transform.x;
|
||||
const y = node.y * transform.zoom + transform.y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
|
||||
};
|
||||
}, [getViewportSize]);
|
||||
return { x: node.x, y: node.y };
|
||||
}, []);
|
||||
|
||||
const setInteractionSelectionDisabled = useCallback((disabled: boolean) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const bodyStyle = document.body.style;
|
||||
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(() => {
|
||||
if (!runningRef.current) return;
|
||||
|
|
@ -266,12 +309,15 @@ export function GraphView({
|
|||
|
||||
// 3. Draw every frame: background stars and shooting stars need continuous motion.
|
||||
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)
|
||||
canvasHandle.current?.draw({
|
||||
teamName: data.teamName,
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
nodes: visibleNodes,
|
||||
edges: visibleEdges,
|
||||
particles: state.particles,
|
||||
effects: state.effects,
|
||||
time: state.time,
|
||||
|
|
@ -282,11 +328,18 @@ export function GraphView({
|
|||
hoveredEdgeId: hoveredEdgeIdRef.current,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
focusEdgeIds: focusState.focusEdgeIds,
|
||||
dragPreview: dragPreviewRef.current,
|
||||
});
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -362,6 +415,18 @@ export function GraphView({
|
|||
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(
|
||||
(e: WheelEvent) => {
|
||||
markUserInteracted();
|
||||
|
|
@ -377,13 +442,20 @@ export function GraphView({
|
|||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return; // only left click
|
||||
e.preventDefault();
|
||||
dragPreviewRef.current = null;
|
||||
setInteractionGuards(true);
|
||||
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
if (!canvas) {
|
||||
setInteractionGuards(false);
|
||||
return;
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const nodes = simulation.stateRef.current.nodes;
|
||||
const edges = simulation.stateRef.current.edges;
|
||||
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 nodeMap = getNodeMap(nodes);
|
||||
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(
|
||||
(e: React.MouseEvent) => {
|
||||
// Dragging with left button held
|
||||
if (e.buttons & 1) {
|
||||
if (isPanningRef.current) {
|
||||
camera.handlePanMove(e.clientX, e.clientY);
|
||||
return;
|
||||
const processActivePointerMove = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!activePrimaryInteractionRef.current) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPanningRef.current) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
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;
|
||||
camera.handlePanMove(clientX, clientY);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No button held — hover detection + cursor update
|
||||
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 = 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;
|
||||
if (!canvas) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
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';
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||
|
||||
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(
|
||||
(e: React.MouseEvent) => {
|
||||
const completePointerInteraction = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const draggedNodeId = interaction.dragNodeId.current;
|
||||
const wasDragging = interaction.isDragging.current;
|
||||
|
||||
if (isPanningRef.current) {
|
||||
camera.handlePanEnd();
|
||||
isPanningRef.current = false;
|
||||
setSelectedNodeId(null); // hide popover after pan
|
||||
setInteractionGuards(false);
|
||||
dragPreviewRef.current = null;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
edgeMouseDownRef.current = null;
|
||||
interaction.handleMouseUp();
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
setSelectedNodeId(clickedId);
|
||||
setSelectedEdgeId(null);
|
||||
|
|
@ -480,7 +609,7 @@ export function GraphView({
|
|||
let clickedEdgeId: string | null = null;
|
||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||
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 dy = world.y - edgeMouseDownRef.current.y;
|
||||
if (dx * dx + dy * dy <= 25) {
|
||||
|
|
@ -499,17 +628,121 @@ export function GraphView({
|
|||
events?.onEdgeClick?.(edge);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodeId(null); // click on empty space — hide popover
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
if (!interaction.isDragging.current && !clickedEdgeId) {
|
||||
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(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
|
|
@ -519,7 +752,7 @@ export function GraphView({
|
|||
const nodeId = interaction.handleDoubleClick(
|
||||
world.x,
|
||||
world.y,
|
||||
simulation.stateRef.current.nodes
|
||||
getVisibleNodes(simulation.stateRef.current.nodes)
|
||||
);
|
||||
if (nodeId) {
|
||||
setSelectedEdgeId(null);
|
||||
|
|
@ -534,7 +767,7 @@ export function GraphView({
|
|||
}
|
||||
}
|
||||
},
|
||||
[camera, interaction, simulation.stateRef, events]
|
||||
[camera, events, getVisibleNodes, interaction, simulation.stateRef]
|
||||
);
|
||||
|
||||
// ─── Keyboard ───────────────────────────────────────────────────────────
|
||||
|
|
@ -579,10 +812,6 @@ export function GraphView({
|
|||
const selectedEdge: GraphEdge | null = selectedEdgeId
|
||||
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
|
||||
: null;
|
||||
const hasBlockingEdges = useMemo(
|
||||
() => data.edges.some((edge) => edge.type === 'blocking'),
|
||||
[data.edges]
|
||||
);
|
||||
const selectedEdgeNodeMap = useMemo(
|
||||
() => getNodeMap(simulation.stateRef.current.nodes),
|
||||
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
|
||||
|
|
@ -660,7 +889,10 @@ export function GraphView({
|
|||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────
|
||||
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
|
||||
ref={canvasHandle}
|
||||
showHexGrid={config?.showHexGrid ?? true}
|
||||
|
|
@ -700,17 +932,24 @@ export function GraphView({
|
|||
onRequestFullscreen={onRequestFullscreen}
|
||||
onOpenTeamPage={onOpenTeamPage}
|
||||
onCreateTask={onCreateTask}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
teamName={data.teamName}
|
||||
teamColor={data.teamColor}
|
||||
isAlive={data.isAlive}
|
||||
topToolbarContent={renderTopToolbarContent?.()}
|
||||
interactionLocked={interactionLocked}
|
||||
/>
|
||||
|
||||
{renderHud ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
||||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
getActivityWorldRect,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
getNodeWorldPosition,
|
||||
getViewportSize,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -338,7 +339,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -365,7 +367,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -392,7 +395,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -419,6 +423,147 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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
|
||||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -854,6 +999,35 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": 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
|
||||
},
|
||||
"azure_ai/claude-opus-4-1": {
|
||||
|
|
@ -1687,7 +1861,8 @@
|
|||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"supports_max_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-6-20260205": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1715,6 +1890,71 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
|
|
@ -4148,7 +4388,8 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": 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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4174,6 +4415,61 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": 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
|
||||
},
|
||||
"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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity';
|
||||
|
||||
import type { GraphActivityItem } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
AttachmentMeta,
|
||||
|
|
@ -18,6 +20,8 @@ export interface InlineActivityEntry {
|
|||
ownerNodeId: string;
|
||||
graphItem: GraphActivityItem;
|
||||
message: InboxMessage;
|
||||
sourceKind: 'message' | 'comment';
|
||||
sourceOrder: number | null;
|
||||
}
|
||||
|
||||
export interface ActivityEntrySourceData {
|
||||
|
|
@ -49,6 +53,10 @@ export function buildInlineActivityEntries({
|
|||
ownerNodeIds,
|
||||
}: BuildInlineActivityEntriesArgs): 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 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 messageSourceOrderByKey = new Map(
|
||||
data.messages.map((message, index) => [getActivityMessageKey(message), index] as const)
|
||||
);
|
||||
for (const message of orderedMessages) {
|
||||
if (message.summary?.startsWith('Comment on ')) {
|
||||
continue;
|
||||
|
|
@ -80,10 +91,10 @@ export function buildInlineActivityEntries({
|
|||
|
||||
const ownerNodeId = resolveMessageOwnerNodeId({
|
||||
message,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
memberNodeIdByAlias,
|
||||
});
|
||||
if (!ownerNodeId) {
|
||||
continue;
|
||||
|
|
@ -113,6 +124,8 @@ export function buildInlineActivityEntries({
|
|||
ownerNodeId,
|
||||
graphItem,
|
||||
message,
|
||||
sourceKind: 'message',
|
||||
sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -123,10 +136,10 @@ export function buildInlineActivityEntries({
|
|||
const ownerNodeId = resolveCommentOwnerNodeId({
|
||||
taskOwner: item.task.owner,
|
||||
author: item.comment.author,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
memberNodeIdByAlias,
|
||||
});
|
||||
if (!ownerNodeId) {
|
||||
continue;
|
||||
|
|
@ -154,14 +167,13 @@ export function buildInlineActivityEntries({
|
|||
task: item.task,
|
||||
comment: item.comment,
|
||||
}),
|
||||
sourceKind: 'comment',
|
||||
sourceOrder: item.sourceOrder,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [ownerNodeId, entries] of entriesByOwnerNodeId) {
|
||||
entriesByOwnerNodeId.set(
|
||||
ownerNodeId,
|
||||
entries.sort((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp))
|
||||
);
|
||||
entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries));
|
||||
}
|
||||
|
||||
return entriesByOwnerNodeId;
|
||||
|
|
@ -169,30 +181,55 @@ export function buildInlineActivityEntries({
|
|||
|
||||
function collectTaskComments(
|
||||
tasks: readonly TeamTaskWithKanban[]
|
||||
): { task: TeamTaskWithKanban; comment: TaskComment }[] {
|
||||
const items: { task: TeamTaskWithKanban; comment: TaskComment }[] = [];
|
||||
): { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] {
|
||||
const items: { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] = [];
|
||||
let sourceOrder = 0;
|
||||
for (const task of tasks) {
|
||||
for (const comment of task.comments ?? []) {
|
||||
items.push({ task, comment });
|
||||
items.push({ task, comment, sourceOrder });
|
||||
sourceOrder += 1;
|
||||
}
|
||||
}
|
||||
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: {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
leadId: string;
|
||||
leadName: string;
|
||||
ownerNodeIds: ReadonlySet<string>;
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||
}): 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') {
|
||||
return leadId;
|
||||
}
|
||||
|
||||
const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName);
|
||||
const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId;
|
||||
const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias);
|
||||
const toId = message.to
|
||||
? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias)
|
||||
: leadId;
|
||||
|
||||
if (toId !== leadId && ownerNodeIds.has(toId)) {
|
||||
return toId;
|
||||
|
|
@ -206,20 +243,20 @@ function resolveMessageOwnerNodeId(args: {
|
|||
function resolveCommentOwnerNodeId(args: {
|
||||
taskOwner: string | undefined;
|
||||
author: string;
|
||||
teamName: string;
|
||||
leadId: string;
|
||||
leadName: string;
|
||||
ownerNodeIds: ReadonlySet<string>;
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||
}): string | null {
|
||||
const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args;
|
||||
const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args;
|
||||
if (taskOwner) {
|
||||
const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName);
|
||||
const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias);
|
||||
if (ownerNodeIds.has(ownerId)) {
|
||||
return ownerId;
|
||||
}
|
||||
}
|
||||
|
||||
const authorId = resolveParticipantId(author, teamName, leadId, leadName);
|
||||
const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias);
|
||||
if (ownerNodeIds.has(authorId)) {
|
||||
return authorId;
|
||||
}
|
||||
|
|
@ -327,9 +364,9 @@ function getActivityMessageKey(message: InboxMessage): string {
|
|||
|
||||
function resolveParticipantId(
|
||||
name: string,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName?: string
|
||||
leadName: string | undefined,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): string {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === 'user' || normalized === 'team-lead') {
|
||||
|
|
@ -338,7 +375,7 @@ function resolveParticipantId(
|
|||
if (normalized === leadName?.trim().toLowerCase()) {
|
||||
return leadId;
|
||||
}
|
||||
return `member:${teamName}:${name}`;
|
||||
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* This is the ONLY file in this feature that imports from @renderer/store.
|
||||
* If the project data model changes, ONLY this class needs updating.
|
||||
* This adapter owns the graph projection from team runtime state into the
|
||||
* 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.
|
||||
*/
|
||||
|
|
@ -22,24 +23,32 @@ import {
|
|||
} from '@shared/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
||||
import {
|
||||
buildInlineActivityEntries,
|
||||
getGraphLeadMemberName,
|
||||
} from '../utils/buildInlineActivityEntries';
|
||||
import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
|
||||
} from '../../core/domain/buildInlineActivityEntries';
|
||||
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
|
||||
import {
|
||||
buildGraphMemberNodeIdAliasMap,
|
||||
buildGraphMemberNodeIdForMember,
|
||||
getGraphStableOwnerId,
|
||||
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
} from '../../core/domain/graphOwnerIdentity';
|
||||
import {
|
||||
isTaskBlocked,
|
||||
isTaskInReviewCycle,
|
||||
resolveTaskReviewer,
|
||||
} from '../utils/taskGraphSemantics';
|
||||
} from '../../core/domain/taskGraphSemantics';
|
||||
|
||||
import type {
|
||||
GraphActivityItem,
|
||||
GraphDataPort,
|
||||
GraphEdge,
|
||||
GraphLayoutPort,
|
||||
GraphNode,
|
||||
GraphNodeState,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphParticle,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -49,6 +58,7 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
TeamData,
|
||||
TeamProcess,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types/team';
|
||||
import type { LeadContextUsage } from '@shared/types/team';
|
||||
|
|
@ -90,12 +100,23 @@ export class TeamGraphAdapter {
|
|||
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||
commentReadState?: Record<string, unknown>,
|
||||
provisioningProgress?: TeamProvisioningProgress | null,
|
||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot
|
||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): GraphDataPort {
|
||||
if (teamData?.teamName !== 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
|
||||
if (teamName !== this.#lastTeamName) {
|
||||
this.#seenMessageIds.clear();
|
||||
|
|
@ -115,6 +136,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
const leadId = `lead:${teamName}`;
|
||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
||||
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
||||
const provisioningPresentation = buildTeamProvisioningPresentation({
|
||||
progress: provisioningProgress,
|
||||
members: teamData.members,
|
||||
|
|
@ -144,6 +166,7 @@ export class TeamGraphAdapter {
|
|||
leadId,
|
||||
teamData,
|
||||
teamName,
|
||||
memberNodeIdByAlias,
|
||||
spawnStatuses,
|
||||
pendingApprovalAgents,
|
||||
activeTools,
|
||||
|
|
@ -152,8 +175,8 @@ export class TeamGraphAdapter {
|
|||
isTeamProvisioning,
|
||||
isLaunchSettling
|
||||
);
|
||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
|
||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||
this.#buildMessageParticles(
|
||||
particles,
|
||||
|
|
@ -162,9 +185,18 @@ export class TeamGraphAdapter {
|
|||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
edges
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
this.#buildCommentParticles(
|
||||
particles,
|
||||
teamData,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
|
|
@ -173,6 +205,7 @@ export class TeamGraphAdapter {
|
|||
teamName,
|
||||
teamColor: teamData.config.color ?? undefined,
|
||||
isAlive: teamData.isAlive,
|
||||
layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +228,97 @@ export class TeamGraphAdapter {
|
|||
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 {
|
||||
if (!timestamp || cutoffMs == null) {
|
||||
return false;
|
||||
|
|
@ -287,6 +411,7 @@ export class TeamGraphAdapter {
|
|||
leadMember?.effort
|
||||
),
|
||||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||
pendingApproval,
|
||||
|
|
@ -324,6 +449,7 @@ export class TeamGraphAdapter {
|
|||
leadId: string,
|
||||
data: TeamData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||
|
|
@ -336,7 +462,8 @@ export class TeamGraphAdapter {
|
|||
if (member.removedAt) 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 activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||
activeTools?.[member.name],
|
||||
|
|
@ -377,6 +504,7 @@ export class TeamGraphAdapter {
|
|||
),
|
||||
spawnStatus: spawn?.status,
|
||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||
currentTaskId: member.currentTaskId ?? undefined,
|
||||
currentTaskSubject: member.currentTaskId
|
||||
|
|
@ -425,7 +553,8 @@ export class TeamGraphAdapter {
|
|||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
teamName: string,
|
||||
commentReadState?: Record<string, unknown>
|
||||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
|
|
@ -446,7 +575,7 @@ export class TeamGraphAdapter {
|
|||
for (const task of data.tasks) {
|
||||
if (task.status === 'deleted') continue;
|
||||
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 reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||
const isReviewCycle = isTaskInReviewCycle(task);
|
||||
|
|
@ -496,7 +625,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
|
||||
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6);
|
||||
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5);
|
||||
const visibleTaskIds = new Set(
|
||||
visibleTaskNodes.flatMap((taskNode) =>
|
||||
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
|
||||
|
|
@ -571,7 +700,10 @@ export class TeamGraphAdapter {
|
|||
|
||||
for (const relatedId of task.related ?? []) {
|
||||
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;
|
||||
this.#seenRelated.add(key);
|
||||
edges.push({
|
||||
|
|
@ -605,18 +737,21 @@ export class TeamGraphAdapter {
|
|||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
teamName: string
|
||||
teamName: string,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
for (const proc of data.processes) {
|
||||
if (proc.stoppedAt) continue;
|
||||
for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses(
|
||||
data.processes,
|
||||
memberNodeIdByAlias
|
||||
)) {
|
||||
const procId = `process:${teamName}:${proc.id}`;
|
||||
const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null;
|
||||
|
||||
nodes.push({
|
||||
id: procId,
|
||||
kind: 'process',
|
||||
label: proc.label,
|
||||
state: 'active',
|
||||
ownerId,
|
||||
processUrl: proc.url ?? undefined,
|
||||
processRegisteredBy: proc.registeredBy ?? 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(
|
||||
nodes: GraphNode[],
|
||||
data: TeamData,
|
||||
|
|
@ -680,7 +857,8 @@ export class TeamGraphAdapter {
|
|||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[]
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const ordered = [...messages].reverse();
|
||||
|
||||
|
|
@ -763,16 +941,22 @@ export class TeamGraphAdapter {
|
|||
continue;
|
||||
}
|
||||
|
||||
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
|
||||
const edgeId = TeamGraphAdapter.#resolveMessageEdge(
|
||||
msg,
|
||||
leadId,
|
||||
leadName,
|
||||
edges,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
if (!edgeId) continue;
|
||||
|
||||
// Determine direction: messages FROM a teammate TO lead should reverse
|
||||
// (edges are always lead→member, but message goes member→lead)
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||
msg.from ?? '',
|
||||
teamName,
|
||||
leadId,
|
||||
leadName
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const isFromTeammate = fromId !== leadId;
|
||||
|
||||
|
|
@ -812,7 +996,8 @@ export class TeamGraphAdapter {
|
|||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[]
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): void {
|
||||
// First call: record current comment counts without creating particles.
|
||||
// This prevents pre-existing comments from spawning particles when the graph opens.
|
||||
|
|
@ -851,9 +1036,9 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
|
||||
newComment.author,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||
const authorEdge =
|
||||
|
|
@ -985,16 +1170,26 @@ export class TeamGraphAdapter {
|
|||
|
||||
static #resolveMessageEdge(
|
||||
msg: InboxMessage,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
edges: GraphEdge[]
|
||||
edges: GraphEdge[],
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): string | null {
|
||||
const { from, to } = msg;
|
||||
|
||||
if (from && to) {
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
|
||||
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName);
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||
from,
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
const toId = TeamGraphAdapter.#resolveParticipantId(
|
||||
to,
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
return (
|
||||
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
||||
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
||||
|
|
@ -1003,7 +1198,12 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
if (from && !to) {
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
|
||||
const fromId = TeamGraphAdapter.#resolveParticipantId(
|
||||
from,
|
||||
leadId,
|
||||
leadName,
|
||||
memberNodeIdByAlias
|
||||
);
|
||||
return (
|
||||
edges.find(
|
||||
(e) =>
|
||||
|
|
@ -1018,14 +1218,14 @@ export class TeamGraphAdapter {
|
|||
|
||||
static #resolveParticipantId(
|
||||
name: string,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName?: string
|
||||
leadName: string | undefined,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): string {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === 'user' || normalized === 'team-lead') 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" */
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
|
||||
|
|
@ -31,6 +33,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
toolHistory,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
slotAssignments,
|
||||
graphLayoutSession,
|
||||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teamData: selectTeamDataForName(s, teamName),
|
||||
|
|
@ -43,6 +48,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
|
||||
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
||||
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 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(
|
||||
() =>
|
||||
adapterRef.current.adapt(
|
||||
|
|
@ -72,7 +127,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
toolHistory,
|
||||
commentReadState,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot
|
||||
memberSpawnSnapshot,
|
||||
effectiveSlotAssignments
|
||||
),
|
||||
[
|
||||
teamData,
|
||||
|
|
@ -87,6 +143,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
commentReadState,
|
||||
provisioningProgress,
|
||||
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 { 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 { TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -63,7 +63,7 @@ export const GraphBlockingEdgePopover = ({
|
|||
onSelectNode,
|
||||
onOpenTaskDetail,
|
||||
}: GraphBlockingEdgePopoverProps): React.JSX.Element => {
|
||||
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
|
||||
const { teamData } = useGraphActivityContext(teamName);
|
||||
const tasksById = useMemo(
|
||||
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)),
|
||||
[teamData?.tasks]
|
||||
|
|
@ -1,22 +1,18 @@
|
|||
/**
|
||||
* GraphNodePopover — renders popover for graph nodes using project UI components.
|
||||
* Lives in features/ (not in package) so it CAN import from @renderer/.
|
||||
* Reuses agentAvatarUrl, status helpers, and UI primitives from the project.
|
||||
* This stays in the renderer slice instead of the reusable package because it
|
||||
* composes project-specific UI, selectors, and presentation helpers.
|
||||
*/
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
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 { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
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';
|
||||
|
||||
|
|
@ -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 (preview.startsWith('{') || preview.startsWith('[')) {
|
||||
try {
|
||||
const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
|
||||
// Common readable fields
|
||||
return (
|
||||
obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined
|
||||
);
|
||||
const parsed: unknown = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const previewRecord = parsed as Record<string, unknown>;
|
||||
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 {
|
||||
// Truncated JSON — extract first quoted value
|
||||
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
|
||||
|
|
@ -202,7 +209,7 @@ const OverflowPopoverContent = ({
|
|||
onClose: () => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
}): 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 hiddenTasks = (node.overflowTaskIds ?? [])
|
||||
.map((taskId) => tasksById.get(taskId) ?? null)
|
||||
|
|
@ -286,16 +293,7 @@ const MemberPopoverContent = ({
|
|||
: '';
|
||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
||||
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,
|
||||
}))
|
||||
);
|
||||
useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
|
|
@ -322,11 +320,17 @@ const MemberPopoverContent = ({
|
|||
: null;
|
||||
const fallbackSpawnStatusLabel =
|
||||
node.spawnStatus && node.spawnStatus !== 'online'
|
||||
? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning'
|
||||
? 'starting'
|
||||
: node.spawnStatus
|
||||
? node.spawnStatus === 'waiting'
|
||||
? 'waiting to start'
|
||||
: node.spawnStatus === 'spawning'
|
||||
? 'starting'
|
||||
: node.spawnStatus === 'error'
|
||||
? 'failed'
|
||||
: node.spawnStatus
|
||||
: null;
|
||||
const statusLabel =
|
||||
launchPresentation?.launchStatusLabel ??
|
||||
node.launchStatusLabel ??
|
||||
launchPresentation?.presenceLabel ??
|
||||
fallbackSpawnStatusLabel ??
|
||||
(node.state === 'active'
|
||||
|
|
@ -421,7 +425,7 @@ const MemberPopoverContent = ({
|
|||
)}
|
||||
</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 */}
|
||||
{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.
|
||||
* 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 { 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 { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { KanbanColumnId, TeamTask } from '@shared/types';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -83,14 +82,10 @@ export const GraphTaskCard = ({
|
|||
onDeleteTask,
|
||||
}: GraphTaskCardProps): React.JSX.Element => {
|
||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||
|
||||
const { task, tasks, members } = useStore(
|
||||
useShallow((s) => ({
|
||||
tasks: selectTeamDataForName(s, teamName)?.tasks ?? [],
|
||||
members: selectTeamDataForName(s, teamName)?.members ?? [],
|
||||
task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId),
|
||||
}))
|
||||
);
|
||||
const { teamData } = useGraphActivityContext(teamName);
|
||||
const tasks = teamData?.tasks ?? [];
|
||||
const members = teamData?.members ?? [];
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
|
||||
const taskMap = useMemo(() => {
|
||||
const map = new Map<string, TeamTask>();
|
||||
|
|
@ -119,8 +114,8 @@ export const GraphTaskCard = ({
|
|||
const columnId = resolveColumn(task);
|
||||
const taskWithKanban = task;
|
||||
|
||||
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
|
||||
fn?.(taskId);
|
||||
const closeAct = (fn?: (id: string) => void) => (nextTaskId: string) => {
|
||||
fn?.(nextTaskId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -7,15 +7,17 @@ import { useCallback, useMemo } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
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 { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -27,6 +29,8 @@ export interface TeamGraphOverlayProps {
|
|||
teamName: string;
|
||||
onClose: () => void;
|
||||
onPinAsTab?: () => void;
|
||||
sidebarVisible?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
onSendMessage?: (memberName: string) => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
|
|
@ -42,16 +46,21 @@ export const TeamGraphOverlay = ({
|
|||
teamName,
|
||||
onClose,
|
||||
onPinAsTab,
|
||||
sidebarVisible,
|
||||
onToggleSidebar,
|
||||
onSendMessage,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||
useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
const leadNodeId = useMemo(
|
||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
||||
[graphData.nodes]
|
||||
);
|
||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||
|
||||
useTeamGraphSlotReset(teamName);
|
||||
|
||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||
const dispatchTaskAction = useCallback(
|
||||
|
|
@ -73,9 +82,9 @@ export const TeamGraphOverlay = ({
|
|||
[dispatchTaskAction]
|
||||
);
|
||||
const openTeamPage = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
openTeamTab();
|
||||
onClose();
|
||||
}, [onClose, teamName]);
|
||||
}, [onClose, openTeamTab]);
|
||||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
|
@ -104,38 +113,56 @@ export const TeamGraphOverlay = ({
|
|||
|
||||
return (
|
||||
<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
|
||||
data={graphData}
|
||||
events={events}
|
||||
isSurfaceActive
|
||||
onRequestClose={onClose}
|
||||
onRequestPinAsTab={onPinAsTab}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
onCreateTask={openCreateTask}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
isSidebarVisible={effectiveSidebarVisible}
|
||||
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
|
||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||
className="team-graph-view min-w-0 flex-1"
|
||||
renderHud={({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds,
|
||||
}) => (
|
||||
<>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
||||
getNodeScreenPosition={getNodeScreenPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
<GraphProvisioningHud
|
||||
teamName={teamName}
|
||||
leadNodeId={leadNodeId}
|
||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
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}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
|
||||
<GraphBlockingEdgePopover
|
||||
teamName={teamName}
|
||||
|
|
@ -7,17 +7,19 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
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 { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
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 {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
|
|
@ -44,13 +46,13 @@ export const TeamGraphTab = ({
|
|||
isPaneFocused = false,
|
||||
}: TeamGraphTabProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const leadNodeId = useMemo(
|
||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
||||
[graphData.nodes]
|
||||
);
|
||||
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
||||
useTeamGraphSlotReset(teamName, isActive);
|
||||
|
||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||
const dispatchOpenTask = useCallback(
|
||||
(taskId: string) =>
|
||||
|
|
@ -73,9 +75,6 @@ export const TeamGraphTab = ({
|
|||
),
|
||||
[teamName]
|
||||
);
|
||||
const openTeamPage = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
}, [teamName]);
|
||||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
|
@ -130,46 +129,65 @@ export const TeamGraphTab = ({
|
|||
|
||||
return (
|
||||
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
|
||||
<TeamSidebarHost
|
||||
teamName={teamName}
|
||||
surface="graph-tab"
|
||||
isActive={isActive}
|
||||
isFocused={isPaneFocused}
|
||||
/>
|
||||
{sidebarVisible ? (
|
||||
<TeamSidebarHost
|
||||
teamName={teamName}
|
||||
surface="graph-tab"
|
||||
isActive={isActive}
|
||||
isFocused={isPaneFocused}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<GraphView
|
||||
data={graphData}
|
||||
events={events}
|
||||
className="team-graph-view size-full"
|
||||
suspendAnimation={!isActive}
|
||||
isSurfaceActive={isActive}
|
||||
onRequestFullscreen={() => setFullscreen(true)}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
onCreateTask={openCreateTask}
|
||||
renderHud={({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
onToggleSidebar={toggleSidebarVisible}
|
||||
isSidebarVisible={sidebarVisible}
|
||||
renderTopToolbarContent={() => (
|
||||
<GraphProvisioningHud teamName={teamName} 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 }) => (
|
||||
<GraphBlockingEdgePopover
|
||||
teamName={teamName}
|
||||
|
|
@ -208,6 +226,8 @@ export const TeamGraphTab = ({
|
|||
<TeamGraphOverlay
|
||||
teamName={teamName}
|
||||
onClose={() => setFullscreen(false)}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={toggleSidebarVisible}
|
||||
onSendMessage={dispatchSendMessage}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
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