dev -> main

Merge pull request #60 from 777genius/dev
This commit is contained in:
Илия 2026-04-18 09:16:02 +03:00 committed by GitHub
commit e163684b69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
362 changed files with 45640 additions and 5650 deletions

7
.github/SECURITY.md vendored
View file

@ -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
View 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).

View file

@ -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

View file

@ -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

View 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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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'),

View file

@ -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

View file

@ -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/*"
],

View file

@ -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.
*/

View file

@ -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;

View file

@ -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

View file

@ -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,

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View 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,
};
}

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -5,9 +5,13 @@ export type {
GraphNode,
GraphEdge,
GraphParticle,
GraphActivityItem,
GraphNodeKind,
GraphNodeState,
GraphEdgeType,
GraphParticleKind,
GraphDomainRef,
GraphOwnerSlotAssignment,
GraphLayoutPort,
GraphLayoutVersion,
} from './types';

View file

@ -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 */

View file

@ -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();
}

View file

@ -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>

View file

@ -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>

View file

@ -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
View 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
View 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/`

View 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

File diff suppressed because it is too large Load diff

View file

@ -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 {

View 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;
}

View file

@ -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" */

View file

@ -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,
}))
);
}

View file

@ -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,
}))
);
}

View file

@ -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,
};
}

View file

@ -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,
]
);
}

View file

@ -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]);
}

View file

@ -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,
};
}

View 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';

View 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}
/>
</>
);
};

View file

@ -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]

View file

@ -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 && (

View 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>
</>
);
};

View file

@ -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();
};

View file

@ -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}

View file

@ -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}

View file

@ -0,0 +1,5 @@
import type { DashboardRecentProjectsPayload } from './dto';
export interface RecentProjectsElectronApi {
getDashboardRecentProjects(): Promise<DashboardRecentProjectsPayload>;
}

View 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';

View 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;
}

View file

@ -0,0 +1,4 @@
export type * from './api';
export * from './channels';
export type * from './dto';
export * from './normalize';

View 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,
};
}

View file

@ -0,0 +1,6 @@
import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAggregate';
export interface ListDashboardRecentProjectsResponse {
projects: RecentProjectAggregate[];
degraded: boolean;
}

View file

@ -0,0 +1,3 @@
export interface ClockPort {
now(): number;
}

View file

@ -0,0 +1,5 @@
import type { ListDashboardRecentProjectsResponse } from '../models/ListDashboardRecentProjectsResponse';
export interface ListDashboardRecentProjectsOutputPort<TViewModel> {
present(response: ListDashboardRecentProjectsResponse): TViewModel;
}

View file

@ -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;
}

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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 };
}
}
}

View file

@ -0,0 +1 @@
export type ProviderId = 'anthropic' | 'codex' | 'gemini';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,3 @@
export type RecentProjectOpenTarget =
| { type: 'existing-worktree'; repositoryId: string; worktreeId: string }
| { type: 'synthetic-path'; path: string };

View file

@ -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);
}

View file

@ -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 };
}
});
}

View file

@ -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);
}

View file

@ -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,
})
),
};
}
}

View file

@ -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();
}
}
}

View file

@ -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,
};
}
}

View file

@ -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 };
},
};
}

View 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';

View 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,
});
}
}

View file

@ -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);
}
);
}
}

View file

@ -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;
}
}

View file

@ -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');
}
}
}
}

View file

@ -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,
};
}
}

View file

@ -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;
}

View file

@ -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),
};
}

View file

@ -0,0 +1 @@
export { createRecentProjectsBridge } from './createRecentProjectsBridge';

View file

@ -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),
}));
}

View file

@ -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 };
}

View file

@ -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,
};
}

View file

@ -0,0 +1,2 @@
export { RecentProjectsSection } from './ui/RecentProjectsSection';
export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory';

View 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>
);
};

View file

@ -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 &quot;{searchQuery}&quot;</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>
);
};

View 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,
};
}

View file

@ -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)
);
}

View file

@ -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);
}

View file

@ -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;
}

View 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;
}

View 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';

View 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;

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,5 @@
export interface TmuxInstallerRunnerPort {
install(): Promise<void>;
cancel(): Promise<void>;
submitInput(input: string): Promise<void>;
}

View file

@ -0,0 +1,5 @@
import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts';
export interface TmuxInstallerSnapshotPort {
getSnapshot(): TmuxInstallerSnapshot;
}

View file

@ -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