Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 | |||
| 804b193ac7 | |||
| d397d3a1f6 | |||
| 67210bcd61 | |||
| 4e0b869dd3 | |||
| cfe832383c | |||
| 0737786faf | |||
| 428519335d | |||
| faa56f5c75 | |||
| 7bc4594b94 | |||
| 6153d50fa8 | |||
| 562f43fd88 | |||
| 8ea40260ad | |||
| 6353e88c9e | |||
| 6cf8a82889 | |||
| 2ea2184372 | |||
| aff863c69f | |||
| 764b967856 | |||
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 | |||
| 952ee7c724 | |||
| 92cee94431 | |||
| 1b561c22d9 | |||
| 32a8887419 | |||
| f77049c238 | |||
| 64017c6ca5 | |||
| 9156781d75 | |||
| 8d48a038c7 | |||
| 59b3ba9e39 | |||
| eab7c437df | |||
| e217314248 | |||
| 26b0fe53c7 | |||
| 4227a97ab1 | |||
| 0004cf7036 | |||
| 75a2de60b2 | |||
| 1aee080fca | |||
| 470f9909b4 | |||
| 5ba9e07d78 | |||
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 | |||
| 229002cc2b | |||
| 27923db4e0 | |||
| 5ab674f134 | |||
| 00a2aedb09 | |||
| 9128352d1b | |||
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 | |||
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 | |||
| b7e81af0b6 | |||
| c90dd7b147 | |||
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 | |||
| 3ed88062d6 | |||
| 27313d4413 | |||
| d4b949a6f4 | |||
| 8930865376 | |||
| 29858ddccb | |||
| 7000a9c5ce | |||
| 99e718ad91 | |||
| 67c948bbe2 | |||
| 8b8582f231 | |||
| b868a2fa85 | |||
| 30bc6debde | |||
| d4052ed235 | |||
| aca35dd4cd | |||
| cc85e35439 | |||
| eb63569324 | |||
| a146284087 | |||
| 61cc37ba32 | |||
| e972267a42 | |||
| 876c6de6d2 | |||
| 3549000fa9 | |||
| 2942ced3ec | |||
| 742e23e418 | |||
| d7c1a10724 | |||
| e63bbf825e | |||
| 2ade30f670 | |||
| 5720e8d5f8 | |||
| 85187dfc78 | |||
| 697384145a | |||
| 114823ceef | |||
| c34931c3f5 | |||
| 17e4321761 | |||
| 0ad4b8c920 | |||
| 6d27c115d8 | |||
| fd4edaac98 | |||
| 22fd4797aa | |||
| ce64132771 | |||
| ed418d5696 | |||
| 42db57ee18 | |||
| acff059e92 | |||
| fef3e640d0 | |||
| 4d4449866b | |||
| 56bf5bbf4d | |||
| 8015730adf | |||
| f6973d24d6 | |||
| 865c6c1d1e | |||
| e38dc5555c | |||
| 8021e569f2 | |||
| 2ae9b06652 | |||
| aca8fd02ee | |||
| 29ed86d672 | |||
| 32839332f2 | |||
| 58623f37c0 | |||
| f3a2f41ed7 | |||
| c9541dc509 | |||
| dcd65f891c | |||
| ba79610697 | |||
| b0788eea80 | |||
| 236e94760f | |||
| 98b1921ca6 | |||
| 7618766861 | |||
| 57849d2f75 | |||
| aef7ec130b | |||
| 6f9b9c36b8 | |||
| dab01b7f2b |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-agent-monitor-plugins",
|
||||
"name": "agenthub",
|
||||
"description": "Official plugin marketplace for AgentHub — the `agenthub` plugin installs the dashboard itself (hooks, server, CLI), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
||||
"owner": {
|
||||
"name": "smartgift",
|
||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||
},
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "agenthub",
|
||||
@@ -16,61 +16,61 @@
|
||||
},
|
||||
{
|
||||
"name": "agenthub-analytics",
|
||||
"path": "plugins/agenthub-analytics",
|
||||
"source": "./plugins/agenthub-analytics",
|
||||
"description": "Deep analytics on Claude Code sessions — token usage (total_input/total_output/total_cache_read/total_cache_write with compaction baselines pre-summed), cost breakdowns via the pricing engine, usage trends over 365 days, cache efficiency, model mix, and productivity scoring.",
|
||||
"tags": ["analytics", "tokens", "cost", "productivity"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-cost-guard",
|
||||
"path": "plugins/agenthub-cost-guard",
|
||||
"source": "./plugins/agenthub-cost-guard",
|
||||
"description": "Budget guardrails — set spend limits, forecast week/month-end cost from daily trends, surface cost-threshold alerts via the alert-rule API, and quantify model-routing savings.",
|
||||
"tags": ["budget", "cost", "forecast", "alerts"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-productivity",
|
||||
"path": "plugins/agenthub-productivity",
|
||||
"source": "./plugins/agenthub-productivity",
|
||||
"description": "Productivity automation — daily standups, weekly/monthly reviews, sprint summaries, time-of-day focus analysis, and workflow optimization using the workflow intelligence API's 11 datasets.",
|
||||
"tags": ["productivity", "reports", "workflows", "standup"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-devtools",
|
||||
"path": "plugins/agenthub-devtools",
|
||||
"source": "./plugins/agenthub-devtools",
|
||||
"description": "Developer tools for the Agent Monitor — session debugging with full event-chain inspection, data-integrity inspection, event tracing, transcript search, hook diagnostics, data export (JSON/CSV/Markdown), and system health checks.",
|
||||
"tags": ["devtools", "debug", "diagnostics", "export"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-insights",
|
||||
"path": "plugins/agenthub-insights",
|
||||
"source": "./plugins/agenthub-insights",
|
||||
"description": "AI-powered insights — pattern detection via workflow intelligence (tool flow transitions, agent co-occurrence), anomaly alerting, trend forecasting, regression watch, benchmarking, optimization recommendations, and session comparison.",
|
||||
"tags": ["insights", "patterns", "anomaly", "optimization"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-sessions",
|
||||
"path": "plugins/agenthub-sessions",
|
||||
"source": "./plugins/agenthub-sessions",
|
||||
"description": "Session forensics — search by project/model/status, reconstruct event timelines, replay transcripts turn-by-turn, roll up activity per working directory, and review stale sessions before cleanup.",
|
||||
"tags": ["sessions", "search", "transcript", "replay"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-workflows",
|
||||
"path": "plugins/agenthub-workflows",
|
||||
"source": "./plugins/agenthub-workflows",
|
||||
"description": "Multi-agent orchestration & fleet intelligence — map the subagent DAG, audit model delegation and effectiveness, report concurrency lanes, trace error propagation by depth, and review Workflow-tool fleet runs.",
|
||||
"tags": ["workflows", "orchestration", "subagents", "fleet"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-quality",
|
||||
"path": "plugins/agenthub-quality",
|
||||
"source": "./plugins/agenthub-quality",
|
||||
"description": "Reliability & SLOs — scan for API errors and tool failures, audit hook delivery health (PreToolUse/PostToolUse balance), track completion/error-rate SLOs with error budgets, and alert on reliability regressions.",
|
||||
"tags": ["reliability", "errors", "slo", "quality"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-config",
|
||||
"path": "plugins/agenthub-config",
|
||||
"source": "./plugins/agenthub-config",
|
||||
"description": "Claude Code config & memory governance — audit skills/subagents/commands/MCP servers/hooks/settings via the Config Explorer API and curate the file-based memory store (CLAUDE.md + per-project auto-memory files).",
|
||||
"tags": ["config", "memory", "governance", "mcp"]
|
||||
},
|
||||
{
|
||||
"name": "agenthub-dashboard",
|
||||
"path": "plugins/agenthub-dashboard",
|
||||
"source": "./plugins/agenthub-dashboard",
|
||||
"description": "Dashboard connector — quick status checks, one-line metrics, live activity watch, and endpoint probing against the Agent Monitor API.",
|
||||
"tags": ["dashboard", "status", "connector"]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"name": "Nguyễn Ngọc Trí Vĩ",
|
||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||
},
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
|
||||
"license": "MIT",
|
||||
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "claude-code"],
|
||||
"commands": [
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Optional dev-container image for AgentHub.
|
||||
# Node 22 to match the production Dockerfile and the project's engines.
|
||||
# Node 22 to match the project's engines.
|
||||
# Adds the native-addon toolchain (better-sqlite3 builds via node-gyp) plus
|
||||
# Python 3 (statusline.py and helper scripts) and the sqlite3 CLI.
|
||||
# Python 3 (scripts/expand-ts-module-docs.py) and the sqlite3 CLI.
|
||||
#
|
||||
# This image is ONLY used by Dev Containers / Codespaces. Host-based development
|
||||
# (npm run dev / npm start) is unaffected.
|
||||
|
||||
@@ -22,13 +22,13 @@ dependencies (`npm run setup`).
|
||||
|
||||
| Component | Detail |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` |
|
||||
| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles |
|
||||
| Python | `python3` / `python` for `statusline.py` and helper scripts |
|
||||
| Python | `python3` / `python` for `scripts/expand-ts-module-docs.py` |
|
||||
| sqlite3 CLI | inspect the dashboard DB during development |
|
||||
| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) |
|
||||
| Features | GitHub CLI |
|
||||
| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, YAML, Tailwind |
|
||||
|
||||
## Everyday commands
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Used only when you choose "Dev Containers: Reopen in Container" (VS Code) or
|
||||
// open the repo in a GitHub Codespace. It changes nothing for host-based dev.
|
||||
//
|
||||
// Covers the full project: the Express server (4820), the React/Vite client
|
||||
// (5173) and the VS Code extension. The native `better-sqlite3`
|
||||
// addon builds here (build-essential + python3 are installed in the Dockerfile).
|
||||
// Covers the full project: the Express server (4820) and the React/Vite
|
||||
// client (5173). The native `better-sqlite3` addon builds here
|
||||
// (build-essential + python3 are installed in the Dockerfile).
|
||||
//
|
||||
// NOTE (issue #193): Claude Code hooks are a HOST-side concern. This container
|
||||
// intentionally does NOT bind-mount ~/.claude and does NOT install hooks — the
|
||||
@@ -20,10 +20,7 @@
|
||||
},
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
// Lets you build/run the project's own production Dockerfile + docker-compose
|
||||
// from inside the dev container (e.g. to reproduce issue #193 deliberately).
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
// Server (API + WebSocket) and the Vite client dev server. VS Code forwards
|
||||
@@ -40,7 +37,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Install root + client + vscode-extension deps.
|
||||
// Install root + client deps.
|
||||
// Never installs Claude Code hooks (host-only — see note above).
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"waitFor": "postCreateCommand",
|
||||
@@ -59,7 +56,6 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"vitest.explorer",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"redhat.vscode-yaml",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"ms-python.python",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…"
|
||||
echo "▶ Installing server + client dependencies (npm run setup)…"
|
||||
npm run setup
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
data/
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
!README.md
|
||||
images/
|
||||
wiki/
|
||||
index.html
|
||||
og-image.svg
|
||||
favicon.svg
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
@@ -13,13 +13,3 @@
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.db binary
|
||||
|
||||
# ── GitHub Linguist overrides — keep language stats honest ──────────────────
|
||||
# wiki/mermaid.min.js is a vendored, minified third-party library (Mermaid);
|
||||
# it is not project source code and must not count toward language stats.
|
||||
wiki/mermaid.min.js linguist-vendored
|
||||
|
||||
# wiki/i18n-content.js is machine-assembled translation data (see its header:
|
||||
# "AUTO-GENERATED wiki body-content translations ... Do not hand-edit").
|
||||
# It is a data bundle keyed by English innerHTML, not hand-written JavaScript.
|
||||
wiki/i18n-content.js linguist-generated
|
||||
|
||||
@@ -9,17 +9,5 @@ package-lock.json
|
||||
client/package-lock.json
|
||||
.vs/
|
||||
*.md
|
||||
index.html
|
||||
wiki/index.html
|
||||
*.yml
|
||||
*.yaml
|
||||
desktop/out/
|
||||
desktop/release/
|
||||
desktop/node_modules/
|
||||
desktop/package-lock.json
|
||||
desktop/assets/*.svg
|
||||
desktop/assets/*.png
|
||||
desktop/assets/*.icns
|
||||
desktop/assets/icon.iconset/
|
||||
wiki/mermaid.min.js
|
||||
fonts/*.woff2
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Superpowers Workspace
|
||||
|
||||
This directory contains project-specific configuration and artifacts for the Superpowers workflow.
|
||||
|
||||
It acts as the working memory layer for agent-driven development, enabling structured planning, execution, and iteration across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Superpowers transforms coding agents into structured, process-driven collaborators. Instead of jumping straight into code, the agent:
|
||||
|
||||
1. Clarifies intent
|
||||
2. Produces a design
|
||||
3. Breaks work into executable steps
|
||||
4. Executes via subagents
|
||||
5. Verifies and iterates
|
||||
|
||||
This directory stores the outputs of that workflow so progress is persistent, inspectable, and reproducible.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Typical contents may include:
|
||||
|
||||
```
|
||||
.superpowers/
|
||||
├── brainstorm/ # Design explorations and refined specs
|
||||
├── plans/ # Task breakdowns and execution plans
|
||||
├── reviews/ # Code review outputs and feedback
|
||||
├── runs/ # Execution logs or agent traces
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
> Exact structure may evolve depending on which skills are triggered.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
Superpowers operates through automatic skill activation:
|
||||
|
||||
### 1. Brainstorming
|
||||
|
||||
* Refines vague ideas into concrete specs
|
||||
* Explores alternatives
|
||||
* Produces structured, reviewable design docs
|
||||
|
||||
### 2. Planning
|
||||
|
||||
* Converts approved designs into granular tasks
|
||||
* Each task is:
|
||||
|
||||
* Small (2–5 min)
|
||||
* Explicit (exact file paths + code)
|
||||
* Verifiable
|
||||
|
||||
### 3. Execution
|
||||
|
||||
* Tasks are executed by subagents
|
||||
* Includes:
|
||||
|
||||
* Spec compliance checks
|
||||
* Code quality review
|
||||
* Can run sequentially or in parallel
|
||||
|
||||
### 4. Verification
|
||||
|
||||
* Enforces test-first development (TDD)
|
||||
* Ensures correctness before completion
|
||||
* Prevents silent regressions
|
||||
|
||||
### 5. Completion
|
||||
|
||||
* Validates final state
|
||||
* Offers merge / PR / discard options
|
||||
* Cleans up working branches
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
* **Test-first development** (RED → GREEN → REFACTOR)
|
||||
* **Small, deterministic tasks**
|
||||
* **Explicit over implicit**
|
||||
* **Process over intuition**
|
||||
* **Verification over assumption**
|
||||
|
||||
---
|
||||
|
||||
## How to Use
|
||||
|
||||
You don’t interact with this directory directly most of the time.
|
||||
|
||||
Instead:
|
||||
|
||||
* Start a task in your coding agent (Claude, Cursor, etc.)
|
||||
* Let Superpowers skills activate automatically
|
||||
* Review outputs when prompted (designs, plans, reviews)
|
||||
|
||||
Artifacts will be written here as the workflow progresses.
|
||||
|
||||
---
|
||||
|
||||
## When to Look Here
|
||||
|
||||
Check this directory when you want to:
|
||||
|
||||
* Review the current plan
|
||||
* Inspect prior design decisions
|
||||
* Debug agent behavior
|
||||
* Resume interrupted work
|
||||
* Audit what was executed
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
* Files here are **source-of-truth for agent state**
|
||||
* Safe to commit (recommended for team workflows)
|
||||
* Avoid manual edits unless you understand the workflow implications
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
* Project root `CLAUDE.md` → global agent context
|
||||
* `.claude/` → rules, skills, and subagents
|
||||
* Superpowers upstream docs →
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"reason":"idle timeout","timestamp":1774536800234}
|
||||
@@ -1 +0,0 @@
|
||||
1542
|
||||
+146
-253
File diff suppressed because one or more lines are too long
-67
@@ -1,67 +0,0 @@
|
||||
# Multi-stage Dockerfile for AgentHub - a Node.js server with a React client.
|
||||
# This setup optimizes the final image size by separating the build and runtime stages.
|
||||
# The first stage installs only the production dependencies for the server, while the second stage builds the React client.
|
||||
# The final stage combines the necessary files and dependencies to run the application in production.
|
||||
# Compatibility: This setup is compatible with both Podman and Docker. Runnable on any platform that supports Node.js and Alpine Linux.
|
||||
#
|
||||
# Author: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
|
||||
# ── Stage 1: Install server production deps ───────────────────────────
|
||||
FROM node:22-alpine AS server-deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# The root `postinstall` hook (scripts/postinstall.js) fires during `npm ci`, so
|
||||
# the file must exist here or npm aborts with MODULE_NOT_FOUND before installing
|
||||
# anything. It self-skips when client/ is absent (as it is in this stage), so
|
||||
# copying just the one script keeps this deps-cache layer from busting on
|
||||
# unrelated scripts/ edits. Do NOT use --ignore-scripts: that would also skip
|
||||
# better-sqlite3's prebuild fetch and silently drop the native SQLite driver.
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ── Stage 2: Build React client ───────────────────────────────────────
|
||||
FROM node:22-alpine AS client-build
|
||||
WORKDIR /app/client
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
# vite.config.ts stamps the UI version from the repo-root package.json (one level
|
||||
# up from the client dir). Provide it here so the built client shows the real
|
||||
# release version; the config falls back gracefully if it is ever absent.
|
||||
COPY package.json /app/package.json
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Production runtime ───────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||
RUN apk add --no-cache tmux
|
||||
|
||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||
COPY package.json ./
|
||||
COPY server/ ./server/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY statusline/ ./statusline/
|
||||
COPY --from=client-build /app/client/dist ./client/dist/
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
EXPOSE 4820
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# In a container the app MUST bind all interfaces: the server binds loopback by
|
||||
# default (GHSA-gr74-4xfh-6jw9), but a container's loopback is a separate
|
||||
# namespace the published port cannot reach, so a loopback bind makes the port
|
||||
# unreachable. Inside a container the trust boundary is the *host* port publish
|
||||
# (keep it on 127.0.0.1 — see docker-compose.yml / INSTALL.md), not this bind.
|
||||
ENV DASHBOARD_HOST=0.0.0.0
|
||||
|
||||
# The recommended ~/.claude bind mount is read-only, so the server cannot write
|
||||
# its default data dir (~/.claude/agenthub). Persist to the mounted
|
||||
# volume at /app/data instead.
|
||||
ENV DASHBOARD_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
+128
-92
@@ -7,8 +7,8 @@ A step-by-step guide to get AgentHub — the local control surface for Claude Co
|
||||
If all you want is a working dashboard, skip every step below. From Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The next session start installs the hooks, boots the server, puts the `agenthub` CLI
|
||||
@@ -30,17 +30,117 @@ dashboard, run it from source, or stay on Node 20.
|
||||
| Node.js | 20+ (22+ recommended) | Required for server and client |
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Python | 3.6+ | Optional — statusline utility only |
|
||||
| Git | Any | For cloning the repository |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
|
||||
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
|
||||
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
|
||||
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
|
||||
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — System packages
|
||||
|
||||
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
|
||||
backs the run console**. The dashboard shells out to the `tmux` binary and has
|
||||
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
|
||||
fail while everything else keeps working.
|
||||
|
||||
```bash
|
||||
sudo apt install git tmux # Debian / Ubuntu
|
||||
sudo dnf install git tmux # Fedora / RHEL
|
||||
brew install git tmux # macOS
|
||||
sudo pacman -S git tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
|
||||
Check it:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
git --version
|
||||
node -v # v20.x / v22.x / v24.x
|
||||
```
|
||||
|
||||
Once the server is up you can ask it directly — this is the same probe the
|
||||
dashboard uses:
|
||||
|
||||
```bash
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
**No configuration is needed.** AgentHub creates its own session per lane,
|
||||
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
|
||||
depends on (mouse mode is set on the session explicitly). You can attach to the
|
||||
exact same pane from a normal terminal:
|
||||
|
||||
```bash
|
||||
agenthub lanes shell # from inside the lane's directory
|
||||
tmux attach -t agenthub-lane-7 # or by name
|
||||
tmux ls # every dashboard session is prefixed agenthub-lane-
|
||||
```
|
||||
|
||||
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **No systemd, so no auto-start on boot.** The server is a detached process
|
||||
that runs until logout or reboot. It comes back at the next Claude Code
|
||||
session start (within about 10 s), or by hand with `agenthub start`. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot, open `claude`, and the dashboard comes back within about 10 s.
|
||||
9. `/agenthub-update` completes and the server restarts on the new version.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git
|
||||
cd Claude-Code-Monitor
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
```
|
||||
|
||||
---
|
||||
@@ -85,7 +185,7 @@ This starts two processes concurrently:
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard in Docker or Podman, install hooks from the host with `npm run install-hooks` after the container is up.
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,8 +220,6 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
|
||||
2. Once installed, the dashboard launches in its own window with no browser chrome
|
||||
3. Offline support: previously visited pages and assets are served from the Service Worker cache when the network is unavailable
|
||||
|
||||
The landing page and wiki are also installable PWAs with their own manifests and service workers — visit each in a browser to install independently.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — (Optional) Import existing Claude Code history
|
||||
@@ -223,89 +321,6 @@ See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
---
|
||||
|
||||
## Optional: VS Code extension
|
||||
|
||||
**AgentHub** is also available as a dedicated VS Code extension for seamless, integrated monitoring.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
### Features
|
||||
- **Real-time Sidebar**: Monitor agent status, health, and usage stats in the Activity Bar.
|
||||
- **Pulse Status Bar**: High-level session and agent counts in the bottom status bar.
|
||||
- **Direct Navigation**: Jump to specific dashboard pages or recent sessions.
|
||||
- **Embedded Dashboard**: Full dashboard interface within a native VS Code tab.
|
||||
|
||||
### Installation
|
||||
1. Open the [vscode-extension](./vscode-extension) folder in VS Code.
|
||||
2. Install via the Marketplace or package it manually:
|
||||
```bash
|
||||
cd vscode-extension
|
||||
npm install
|
||||
# Generate .vsix for local install
|
||||
npm run package
|
||||
```
|
||||
3. After installation, ensure the main dashboard server is running (`npm run dev`).
|
||||
4. Look for the **Radar icon** in your VS Code Activity Bar.
|
||||
|
||||
For advanced configuration, refer to the [.vscode](./.vscode) and [vscode-extension](./vscode-extension) directories.
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
---
|
||||
|
||||
## Container mode (Docker / Podman)
|
||||
|
||||
The repository includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. Docker and Podman are both supported.
|
||||
|
||||
### Compose
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
```
|
||||
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
### Plain Docker / Podman
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
### Container notes
|
||||
|
||||
| Mount | Purpose |
|
||||
|---|---|
|
||||
| `~/.claude:/root/.claude:ro` | Lets the server import legacy Claude session history |
|
||||
| `~/.claude/agenthub:/app/data` | **Canonical SQLite database** (shared with native installs) |
|
||||
|
||||
> [!NOTE]
|
||||
> The image sets `DASHBOARD_HOST=0.0.0.0` and `DASHBOARD_DATA_DIR=/app/data` internally (baked into the `Dockerfile`). The `0.0.0.0` bind is required because a container's loopback is a separate namespace the published port cannot reach; `/app/data` is bind-mounted to `~/.claude/agenthub` on the host so Docker uses the same database as `npm start`. The trust boundary is the **host** port publish — the examples use `-p 127.0.0.1:4820:4820`, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Claude Code hooks run on the host, not inside the container. After the container is healthy on `http://localhost:4820`, run `npm run install-hooks` on the host so Claude Code posts hook events back to the containerized server. The installer refuses to run inside a container (issue #193) to avoid writing a container-internal handler path into a bind-mounted `~/.claude`; use `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only if you run Claude Code inside the same container.
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `npm run setup` shows `better-sqlite3` errors
|
||||
@@ -346,6 +361,27 @@ npm run setup
|
||||
|
||||
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
|
||||
|
||||
### "Start Run" fails, or the terminal never attaches
|
||||
|
||||
The run console is tmux-backed. Check, in this order:
|
||||
|
||||
```bash
|
||||
tmux -V # not installed → see Step 0
|
||||
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
|
||||
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the server runs with
|
||||
a different `PATH` — most often a systemd `--user` service, which does not read
|
||||
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
|
||||
rather than into a shell-managed prefix, then `agenthub service uninstall &&
|
||||
agenthub service install`.
|
||||
|
||||
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
|
||||
name, so a `claude` installed only through an interactive shell's profile is
|
||||
invisible to a service-managed server. `{"found":false}` for the lane's tool is
|
||||
that case.
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||
|
||||
@@ -94,30 +94,6 @@ import-history: ## Import sessions from ~/.claude/ history
|
||||
clear-data: ## Delete all data rows (preserves schema)
|
||||
node scripts/clear-data.js
|
||||
|
||||
# ─── Docker / Podman ───────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: ## Build dashboard Docker image
|
||||
docker build -t claude-agent-monitor:local .
|
||||
|
||||
.PHONY: docker-up
|
||||
docker-up: ## Start via docker-compose
|
||||
docker compose up -d
|
||||
|
||||
.PHONY: docker-down
|
||||
docker-down: ## Stop docker-compose stack
|
||||
docker compose down
|
||||
|
||||
.PHONY: docker-logs
|
||||
docker-logs: ## Tail docker-compose logs
|
||||
docker compose logs -f
|
||||
|
||||
|
||||
.PHONY: podman-build
|
||||
podman-build: ## Build dashboard Podman image
|
||||
podman build -t localhost/claude-agent-monitor:local .
|
||||
|
||||
|
||||
# ─── Help ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: help
|
||||
|
||||
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
|
||||
## What it does
|
||||
## Main features
|
||||
|
||||
**Observe**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||
targets that fire on them.
|
||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||
startup; archives from another machine come in through Settings → Import
|
||||
History (idempotent, never double-counts tokens).
|
||||
- **Remote sources.** Pull session history from other machines over SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Drive**
|
||||
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. A lane
|
||||
moves through pipeline stages and the dashboard shows where it is.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
@@ -26,15 +40,89 @@ Internal build — all rights reserved.
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
|
||||
setting.
|
||||
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Git, from the dashboard.** Each lane card shows its branch, `↑ahead ↓behind`
|
||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||
Beside the lane sits a collapsible **Changes** panel — an overview of the
|
||||
changed files, a full review of every diff, or one file at a time, comparing
|
||||
either uncommitted work or the whole branch against its base. There is no
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
|
||||
feature from intake to PR, declaring every stage to the dashboard as it goes.
|
||||
|
||||
## Requirements
|
||||
**Configure**
|
||||
|
||||
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
|
||||
are verified on — node 25 currently breaks 6 server tests through a
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark; light:
|
||||
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
|
||||
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Need | Version | Required for |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||
change.
|
||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||
v26.8.1).
|
||||
|
||||
### Installing tmux
|
||||
|
||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||
**Start Run** and `agenthub lanes shell` fail without it. Everything else
|
||||
(sessions, analytics, lanes, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
### The superpowers plugin
|
||||
|
||||
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
|
||||
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
|
||||
@@ -68,8 +156,8 @@ Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
`npm run setup`:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
||||
@@ -83,7 +171,7 @@ Details, including what to delete on uninstall:
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root, client and vscode-extension dependencies
|
||||
npm run setup # root and client dependencies
|
||||
npm run build # builds the client into client/dist
|
||||
npm start # serves the built client and the API on :4820
|
||||
```
|
||||
@@ -148,9 +236,11 @@ A lane is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns
|
||||
the full lifecycle and may reset or remove it, behind a three-check destroy
|
||||
guard and a counted preflight the caller has to echo back.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a lane — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
@@ -169,9 +259,9 @@ also **advances the declared stage** — the one place an inference moves the bl
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Three things help it get there: the run console
|
||||
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
|
||||
from its own pipeline nodes, `agenthub lanes agents install` writes the same
|
||||
itself can say what it finished. Two things help it get there (the run console
|
||||
no longer pre-fills anything into the prompt box — that box is the user's to
|
||||
type): `agenthub lanes agents install` writes the stage-declaration
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
@@ -205,7 +295,9 @@ lane. This is resource namespacing on the host, not a container: lanes run as th
|
||||
same user and share the network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||
`POST /:id/git/:action`), and the full
|
||||
runtime/profile contract.
|
||||
|
||||
## Tests
|
||||
|
||||
+102
-10
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
|
||||
|
||||
## Làm được gì
|
||||
## Chức năng chính
|
||||
|
||||
**Quan sát**
|
||||
|
||||
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
|
||||
được: tool call, token usage, cost, cây subagent, transcript.
|
||||
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
|
||||
pricing sửa được; top tool, agent type và workflow pattern.
|
||||
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
|
||||
target bắn khi rule khớp.
|
||||
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
|
||||
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
|
||||
(idempotent, không bao giờ đếm trùng token).
|
||||
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Điều khiển**
|
||||
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
|
||||
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
|
||||
@@ -26,14 +40,92 @@ Internal build — all rights reserved.
|
||||
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
|
||||
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
|
||||
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
|
||||
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
|
||||
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
|
||||
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
|
||||
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
|
||||
|
||||
## Yêu cầu
|
||||
**Cấu hình**
|
||||
|
||||
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
|
||||
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
|
||||
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
|
||||
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
|
||||
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
||||
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
|
||||
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
|
||||
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
|
||||
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
|
||||
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
|
||||
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
|
||||
tiếng Anh, Việt, Trung và Hàn.
|
||||
|
||||
## Yêu cầu hệ thống
|
||||
|
||||
| Cần | Phiên bản | Dùng cho |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
|
||||
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
|
||||
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
|
||||
|
||||
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
|
||||
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
|
||||
|
||||
### Cài tmux
|
||||
|
||||
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
|
||||
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
|
||||
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
tmux -V # ví dụ: tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
|
||||
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
|
||||
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
|
||||
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
|
||||
|
||||
### Plugin superpowers
|
||||
|
||||
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
|
||||
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
|
||||
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
|
||||
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
|
||||
|
||||
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
|
||||
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
|
||||
thì skill mới resolve được (session sau tự nhận). Cài tay:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
|
||||
|
||||
## Cài như Claude Code plugin
|
||||
|
||||
@@ -41,8 +133,8 @@ Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không c
|
||||
`npm run setup`:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `agenthub` vào PATH và
|
||||
@@ -55,7 +147,7 @@ cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
## Cài từ checkout
|
||||
|
||||
```bash
|
||||
npm run setup # cài dependency cho root, client và vscode-extension
|
||||
npm run setup # cài dependency cho root và client
|
||||
npm run build # build client vào client/dist
|
||||
npm start # serve client đã build + API trên :4820
|
||||
```
|
||||
|
||||
@@ -48,48 +48,11 @@ npm run install-hooks
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Container note: do not rely on hook auto-install from inside Docker or Podman. The hook path written by a container would point at the container filesystem, not the host. Start the container first, then run `npm run install-hooks` on the host. As a safeguard (issue #193), the installer now **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`; the containerized server logs the same guidance instead of silently writing a bad path. If you genuinely run Claude Code inside the same container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
|
||||
> [!NOTE]
|
||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||
|
||||
### Container runtime (Docker / Podman)
|
||||
|
||||
The repo includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. The container image serves the built client and API on port `4820`, stores SQLite data under `/app/data`, and can import legacy Claude history from a read-only `~/.claude` mount.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
|
||||
# Plain Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Plain Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
Container-specific behavior:
|
||||
|
||||
- The dashboard is available at `http://localhost:4820`
|
||||
- The image sets `DASHBOARD_HOST=0.0.0.0` (bind inside the container — its loopback is a separate namespace the published port cannot reach) and `DASHBOARD_DATA_DIR=/app/data` internally; both are baked into the `Dockerfile`
|
||||
- The examples publish on `127.0.0.1` only, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`
|
||||
- `~/.claude:/root/.claude:ro` is used for history import only
|
||||
- `~/.claude/agenthub:/app/data` is the **canonical** SQLite database (shared with native installs)
|
||||
- Claude Code hooks still execute on the host, so install them from the host with `npm run install-hooks`
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -136,39 +99,18 @@ This repository ships extension files for both agent ecosystems:
|
||||
|
||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
### VS Code extension setup
|
||||
|
||||
**AgentHub** is available as an integrated VS Code extension for seamless monitoring within your editor.
|
||||
|
||||
- **Activity Bar View**: Adds a custom "Radar" icon to the activity bar providing real-time agent health, token counts, and session stats.
|
||||
- **Status Bar Integration**: Displays live session and agent pulse counts in the bottom bar.
|
||||
- **Embedded Dashboard**: Renders the full web dashboard directly in a VS Code editor tab.
|
||||
- **Automated Detection**: Automatically finds your dashboard server on ports `5173` or `4820`.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
To install or develop the extension:
|
||||
1. Open the [vscode-extension](./vscode-extension) directory in VS Code.
|
||||
2. Run `npm install` and `npm run package` to generate a local `.vsix` installer.
|
||||
3. For developer details, see [vscode-extension/README.md](./vscode-extension/README.md).
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
### PWA configuration (optional)
|
||||
|
||||
The dashboard, landing page, and wiki each ship as independent Progressive Web Apps. No configuration is required — manifests and service workers are included out of the box.
|
||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||
|
||||
**Customising the manifest:** Edit the `manifest.json` in the relevant directory (`client/public/` for dashboard, root for landing, `wiki/` for wiki). Common fields to change:
|
||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||
|
||||
- `name` / `short_name` — displayed on the home screen / dock
|
||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||
- `background_color` — splash screen background
|
||||
- `start_url` — entry point when launched from home screen
|
||||
|
||||
**Updating the service worker cache:** Each SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
|
||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||
|
||||
@@ -349,16 +291,6 @@ Commonly used targets:
|
||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||
| `make seed` | `npm run seed` | Load demo data |
|
||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||
| `make docker-up` | `docker compose up -d` | Start via docker-compose |
|
||||
| `make docker-down` | `docker compose down` | Stop docker-compose stack |
|
||||
|
||||
---
|
||||
|
||||
## Statusline (optional)
|
||||
|
||||
The `statusline/` directory contains a standalone terminal statusline for Claude Code showing model, working directory, git branch, context window usage, and token counts. It is independent of the web dashboard.
|
||||
|
||||
See [statusline/README.md](./statusline/README.md) for installation instructions.
|
||||
|
||||
---
|
||||
|
||||
@@ -392,6 +324,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
|
||||
|
||||
---
|
||||
|
||||
### The run console won't start a run / the terminal never attaches
|
||||
|
||||
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
|
||||
|
||||
```bash
|
||||
tmux -V # missing → sudo apt install tmux
|
||||
curl -s http://localhost:4820/api/run/tmux # {"available":true}
|
||||
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the **server's**
|
||||
`PATH` differs from yours — typically a systemd `--user` service, which never
|
||||
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
|
||||
re-run `agenthub service uninstall && agenthub service install`. The same
|
||||
applies to `claude` / `codex`: a run launches them by name.
|
||||
|
||||
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
|
||||
|
||||
---
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
**Check 1 — Is the server running?**
|
||||
@@ -464,36 +416,3 @@ And make sure Claude Code posts hooks to the new port:
|
||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||
# or edit scripts/hook-handler.js and change the default port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Docker / Podman container starts but no sessions appear
|
||||
|
||||
**Check 1 — Is the container healthy?**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
**Check 2 — Did you install hooks on the host?**
|
||||
|
||||
Hooks run on the host machine, not inside the container. After the container is up:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
**Check 3 — Are hooks pointing to the right port?**
|
||||
|
||||
Open `~/.claude/settings.json` and verify the hook commands reference `localhost:4820` (or whatever port the container is mapped to). If you changed the port mapping, update hooks accordingly.
|
||||
|
||||
---
|
||||
|
||||
### Docker build fails during `npm ci`
|
||||
|
||||
If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and should not block the build — `better-sqlite3` is an optional dependency. If the build still fails:
|
||||
|
||||
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
||||
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
||||
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: backend-reviewer
|
||||
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: opus
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
|
||||
+43
-26
@@ -1190,12 +1190,15 @@ async function cmdPricing(flags, positional) {
|
||||
// ── Updates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function cmdUpdateCheck() {
|
||||
console.log(c.dim("Checking the canonical remote — this can take a few seconds…"));
|
||||
console.log(c.dim("Checking the repository's latest release — this can take a few seconds…"));
|
||||
// POST /check (rather than GET /status) so a dashboard open in the browser
|
||||
// sees the same fresh result via the update_status websocket broadcast.
|
||||
const s = await post("/api/updates/check");
|
||||
heading("Dashboard updates", s.repo_root || baseUrl());
|
||||
if (!s.git_repo) {
|
||||
// A plugin-cache install legitimately has no clone (an archive-sourced
|
||||
// marketplace entry unpacks one), yet it IS checkable and updatable via the
|
||||
// release API — so only a genuine non-git checkout bails out here.
|
||||
if (!s.git_repo && s.install_kind !== "plugin") {
|
||||
console.log(`${c.yellow("○")} ${s.message}`);
|
||||
return;
|
||||
}
|
||||
@@ -1203,6 +1206,13 @@ async function cmdUpdateCheck() {
|
||||
console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`);
|
||||
return;
|
||||
}
|
||||
// Say WHY the release lookup produced nothing, so a green "up to date" is
|
||||
// never mistaken for "the release list was actually read".
|
||||
if (s.release_check && s.release_check !== "ok") {
|
||||
console.log(
|
||||
c.dim(` could not read the latest release (${s.release_check}) — compared commits instead`)
|
||||
);
|
||||
}
|
||||
if (s.update_available) {
|
||||
console.log(`${c.yellow("⬆")} ${s.message}`);
|
||||
if (s.situation_note) console.log(c.dim(` ${s.situation_note}`));
|
||||
@@ -1269,6 +1279,23 @@ async function cmdDoctor() {
|
||||
);
|
||||
console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`);
|
||||
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available } = await get("/api/run/tmux");
|
||||
console.log(
|
||||
available
|
||||
? `${c.green("✔")} tmux available to the server`
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
|
||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||
);
|
||||
} catch {
|
||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { sources = [] } = await get("/api/remote-sources");
|
||||
if (sources.length === 0) {
|
||||
@@ -1772,9 +1799,14 @@ async function cmdLanesLifecycle(action, args) {
|
||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
console.log(` database: ${preflight.database} (${fate})`);
|
||||
}
|
||||
if (action === "remove") {
|
||||
console.log(
|
||||
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
|
||||
);
|
||||
}
|
||||
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
||||
@@ -1786,9 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the
|
||||
// server: it drops the dashboard's record and never touches the directory
|
||||
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
|
||||
// refusing it here would make a permitted action unreachable from the CLI.
|
||||
// server for every lane kind: it drops the dashboard's record and never touches
|
||||
// the directory, so refusing it here would make a permitted action unreachable
|
||||
// from the CLI.
|
||||
if (
|
||||
action === "reset" &&
|
||||
Array.isArray(preflight.blocked) &&
|
||||
@@ -2766,22 +2798,6 @@ async function cmdStage(args) {
|
||||
result: flag("result"),
|
||||
});
|
||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||
|
||||
// A stage name matching no node (nor alias) still stores — setStage takes the
|
||||
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
|
||||
// `current` and progress reads 0. Warn, never fail: a typo must not break a
|
||||
// declaration the pipeline can still record, but it must not pass silently.
|
||||
// Skipped for `--result fail`, which paints the node `failed` rather than
|
||||
// `current` and would otherwise look identical to an unknown stage.
|
||||
const nodes = lane.pipeline_nodes || [];
|
||||
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
|
||||
console.error(
|
||||
c.yellow(
|
||||
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
|
||||
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||
@@ -2925,7 +2941,7 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
|
||||
],
|
||||
[
|
||||
"lanes up|down",
|
||||
@@ -3001,12 +3017,12 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"Administration",
|
||||
[
|
||||
["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
|
||||
["doctor", "", "Connectivity, hooks, database, tmux, and remote sources diagnosis"],
|
||||
["info", "", "Raw system info JSON"],
|
||||
["export", "[file.json]", "Export all data as JSON"],
|
||||
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
|
||||
["reinstall-hooks", "", "Reinstall Claude Code hooks"],
|
||||
["update-check", "", "Check whether the dashboard checkout is behind upstream"],
|
||||
["update-check", "", "Check whether a newer release of the dashboard was published"],
|
||||
["clear-data", "--yes", "Delete ALL data (requires --yes)"],
|
||||
["open", "", "Open the dashboard in your browser"],
|
||||
["version", "", "Print the agenthub version (also: --version, -v)"],
|
||||
@@ -3270,7 +3286,8 @@ const SERVER_ONLY_REASONS = {
|
||||
cleanup: "cleanup is a server-side mutation",
|
||||
"clear-data": "data wipes must go through the server",
|
||||
"reinstall-hooks": "hook installation is performed by the server",
|
||||
"update-check": "the update check runs server-side (git fetch against the canonical remote)",
|
||||
"update-check":
|
||||
"the update check runs server-side (reads the repo's latest release, git fetch as fallback)",
|
||||
info: "system info (uptime, memory, WS connections) only exists on a running server",
|
||||
health: "health is, by definition, a check against the running server",
|
||||
};
|
||||
|
||||
+31
-4
@@ -19,8 +19,6 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -54,7 +52,17 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||||
- **React Router 6.28** - Client-side routing with nested layouts
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
@@ -200,7 +208,7 @@ client/
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
@@ -728,6 +736,25 @@ flowchart LR
|
||||
|
||||
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
|
||||
|
||||
#### Tooltips (`components/Tooltip.tsx`)
|
||||
|
||||
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
|
||||
app the same designed tooltip — there is no wrapper to add at the call site.
|
||||
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
|
||||
focus (immediately) it takes the element's native `title`, shows it as an
|
||||
anchored bubble with an arrow, and puts the attribute back when the bubble
|
||||
closes. Consequences worth knowing:
|
||||
|
||||
- **Write `title="…"` exactly as before.** That is the whole API. A control
|
||||
keeps its native tooltip, and its accessible name, when JS has not run.
|
||||
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
|
||||
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
|
||||
in light mode; it flips above/below, clamps to the viewport, and closes on
|
||||
scroll, resize, pointer-down or `Escape`.
|
||||
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
|
||||
expanding a truncated **number** inside a line of text, but draws the same
|
||||
surface so the two read as one thing.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+29
-11
@@ -1,15 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
var d =
|
||||
m === "light"
|
||||
? false
|
||||
: m === "system"
|
||||
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
: true;
|
||||
var r = document.documentElement;
|
||||
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - Claude Code Monitor</title>
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -23,8 +41,8 @@
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta property="og:site_name" content="AgentHub" />
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -33,17 +51,17 @@
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -51,7 +69,7 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Agent Dashboard",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
@@ -61,8 +79,8 @@
|
||||
"name": "SmartGift",
|
||||
"url": "https://smartgift.vn"
|
||||
},
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -13,15 +13,15 @@
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
|
||||
+7
-1
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window" }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.focus) {
|
||||
if ("focus" in client) {
|
||||
// Reuse an already-open tab instead of stacking a new one — that's
|
||||
// also why `navigate` (same-origin only) beats postMessage: it works
|
||||
// even if the page hasn't wired up a listener for this.
|
||||
if (url && "navigate" in client) client.navigate(url).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (url && clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useCallback } from "react";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { DocumentTitle } from "./components/DocumentTitle";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
@@ -87,6 +88,9 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
{/* One renderer gives every `title` in the app the designed bubble —
|
||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||
<GlobalTooltip />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
|
||||
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
|
||||
isMain
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
|
||||
@@ -174,7 +174,7 @@ export function DateTimePicker({
|
||||
? "bg-accent text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
@@ -201,7 +201,10 @@ export function DateTimePicker({
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||
<X
|
||||
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -216,7 +219,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -228,7 +231,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||
if (typeof value === "boolean") {
|
||||
const color = value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||
|
||||
@@ -625,7 +625,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-400"
|
||||
color="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,12 +672,12 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
color="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
value={backupResult.model_pricing}
|
||||
color="text-cyan-300"
|
||||
color="text-cyan-700 dark:text-cyan-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
@@ -737,7 +737,7 @@ function ModeButton({
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-400" : "text-fg-secondary"
|
||||
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface PageTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
/** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface PageTabsProps {
|
||||
@@ -34,7 +32,7 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
aria-label={ariaLabel}
|
||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||
>
|
||||
{tabs.map(({ id, label, Icon, badge }) => {
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const isActive = id === activeId;
|
||||
return (
|
||||
<button
|
||||
@@ -51,7 +49,6 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
{badge && <span className="text-[10px] text-fg-muted">{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -95,21 +95,21 @@ function StatTile({
|
||||
}) {
|
||||
const palette = {
|
||||
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
|
||||
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-fg-muted",
|
||||
violet: "text-violet-400",
|
||||
violet: "text-violet-700 dark:text-violet-400",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
rose: "text-rose-700 dark:text-rose-400",
|
||||
cyan: "text-cyan-700 dark:text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
}[tone];
|
||||
|
||||
@@ -359,7 +359,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
Top tools
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
@@ -418,7 +418,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
|
||||
Subagents
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Cache read",
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Output",
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Monitor,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -92,6 +92,13 @@ const NAV_KEYS = [
|
||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -171,7 +178,7 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme, toggleTheme } = useTheme();
|
||||
const { theme, mode, setMode, toggleTheme } = useTheme();
|
||||
// Track whether nav items are clipped by overflow so we can render
|
||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||
@@ -353,15 +360,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
};
|
||||
|
||||
const updateAvailable = Boolean(updateStatus?.update_available);
|
||||
// Four resting states, not two: a release is out, this build is ahead of the
|
||||
// newest release (dev build), it matches the newest release, or no release
|
||||
// could be read at all. Collapsing the middle two into "Up to date" is what
|
||||
// made a checkout carrying unreleased commits look shipped.
|
||||
const checkTitle = checking
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? t("nav:updateAvailable")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
@@ -389,15 +404,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
|
||||
{/* Wordmark under the logo: small caps + wide tracking so it reads
|
||||
as a caption to the logo rather than competing with it. The
|
||||
hairlines either side keep it centred on a narrow rail. */}
|
||||
<div className="mt-3 flex w-full items-center gap-2 px-1">
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
|
||||
{t("nav:brand")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,7 +520,9 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:language")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{/* One column per language: a wider grid squeezed each button
|
||||
below the width of its own label once Theme grew a third. */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const active = language === currentLanguage;
|
||||
return (
|
||||
@@ -507,7 +532,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:languageNames.${language}`)}
|
||||
title={t(`nav:languageNames.${language}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
||||
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -523,13 +548,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:theme")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{(["dark", "light"] as const).map((option) => {
|
||||
const active = option === theme;
|
||||
<div className="mt-2 grid grid-cols-3 gap-1">
|
||||
{(["dark", "light", "system"] as const).map((option) => {
|
||||
const active = option === mode;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
onClick={() => setMode(option)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:themeNames.${option}`)}
|
||||
title={t(`nav:themeNames.${option}`)}
|
||||
@@ -541,8 +566,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{option === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
) : option === "light" ? (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||
cfg.urgent
|
||||
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Look
|
||||
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
|
||||
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
|
||||
* are visibly one thing. It stays cursor-following (and not anchored like the
|
||||
* global one) because it expands a NUMBER inside a line of text, where the
|
||||
* anchor is a few characters wide and the cursor is the real subject.
|
||||
*
|
||||
* ## Portal rendering
|
||||
* Tooltip content is portaled to `document.body` with `position: fixed` so
|
||||
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
|
||||
@@ -56,6 +63,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TOOLTIP_SURFACE } from "./Tooltip";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @file Tooltip.tsx
|
||||
* @description The dashboard's tooltip look, and the one renderer that gives it
|
||||
* to every control in the app.
|
||||
*
|
||||
* ## Why a global renderer instead of a wrapper component
|
||||
* The UI already labels its icon-only controls with the native `title`
|
||||
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
||||
* touching every one of those call sites to get a designed bubble, and the
|
||||
* next `title=` someone writes would be back to the browser's grey box.
|
||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* on the way out. Nothing else in the app has to know it exists, and a control
|
||||
* keeps its native tooltip (and its accessible name) when JS has not run.
|
||||
* While the title is parked, a control with no other name (icon-only) carries
|
||||
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
|
||||
* bubble goes away with its control if that is removed from the page.
|
||||
*
|
||||
* ## The look
|
||||
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
||||
* hard to read and lands over the thing it describes. It flips above/below and
|
||||
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
||||
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
||||
* built from the theme's own tokens so it is legible in light mode too (the
|
||||
* old bubble was a hardcoded near-black).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
||||
export const TOOLTIP_SURFACE =
|
||||
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
||||
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
||||
"whitespace-pre-wrap break-words";
|
||||
|
||||
/** How long the pointer must rest on a control before its tooltip appears. */
|
||||
const SHOW_DELAY_MS = 350;
|
||||
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
||||
const GAP = 8;
|
||||
|
||||
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
|
||||
* `[data-tip]` matches the control whose title is parked, so moving between
|
||||
* its own children still resolves to it instead of re-opening the bubble. */
|
||||
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
||||
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
|
||||
/**
|
||||
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||
* designed tooltip; nothing else has to change.
|
||||
*/
|
||||
export function GlobalTooltip() {
|
||||
const [tip, setTip] = useState<Anchored | null>(null);
|
||||
const [pos, setPos] = useState<Placed | null>(null);
|
||||
const bubble = useRef<HTMLDivElement>(null);
|
||||
// The element whose `title` is currently parked in `data-tip`, so it can be
|
||||
// put back even if the pointer never leaves it (a route change, a re-render
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
// The element whose delay is running, so re-entering it does not restart it.
|
||||
const pending = useRef<HTMLElement | null>(null);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
// React re-set `title` while it was parked (a poll flipped the hint):
|
||||
// that value is current, the parked copy is stale. React never
|
||||
// re-applies an unchanged prop, so writing the old one back would win
|
||||
// for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
setPos(null);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(el: HTMLElement, delay: number) => {
|
||||
const text = (el.getAttribute("title") || "").trim();
|
||||
if (!text) return;
|
||||
restore();
|
||||
pending.current = el;
|
||||
timer.current = window.setTimeout(() => {
|
||||
pending.current = null;
|
||||
// Re-read at fire time: the control may have been re-rendered, removed
|
||||
// or had its title changed while the delay was running.
|
||||
if (!el.isConnected) return;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
// The title was an icon-only control's ONLY name; keep it named.
|
||||
if (
|
||||
!el.hasAttribute("aria-label") &&
|
||||
!el.hasAttribute("aria-labelledby") &&
|
||||
!el.textContent?.trim()
|
||||
) {
|
||||
el.setAttribute("aria-label", current);
|
||||
el.dataset.tipAria = "";
|
||||
}
|
||||
parked.current = el;
|
||||
setTip({ text: current, rect: el.getBoundingClientRect() });
|
||||
}, delay);
|
||||
},
|
||||
[restore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const target = (e: Event) => {
|
||||
const node = e.target;
|
||||
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
// Already showing or already counting down for this control: a pointer
|
||||
// crossing its children must neither hide nor restart it.
|
||||
if (!el || el === parked.current || el === pending.current) return;
|
||||
open(el, SHOW_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: PointerEvent) => {
|
||||
// Moving between children of the same control must not flicker.
|
||||
const el = target(e);
|
||||
const to = e.relatedTarget;
|
||||
if (el && to instanceof Node && el.contains(to)) return;
|
||||
if (el || parked.current) restore();
|
||||
};
|
||||
// Keyboard users get the same tooltip, immediately — a focus ring that
|
||||
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
||||
// Only KEYBOARD focus, though: a click focuses too, and without the
|
||||
// `:focus-visible` check it reopened the bubble the pointerdown just
|
||||
// closed, with no delay. Hover keeps its own path.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const el = target(e);
|
||||
if (!el || el === parked.current) return;
|
||||
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
|
||||
open(el, 0);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") restore();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerover", onOver);
|
||||
document.addEventListener("pointerout", onOut);
|
||||
document.addEventListener("pointerdown", restore);
|
||||
document.addEventListener("focusin", onFocus);
|
||||
document.addEventListener("focusout", restore);
|
||||
document.addEventListener("keydown", onKey);
|
||||
// A bubble anchored to a rect is wrong the moment its anchor moves — but
|
||||
// only a scroll of the document or of a box containing the anchor moves
|
||||
// it. A log tailing in another panel must not close the bubble.
|
||||
const onScroll = (e: Event) => {
|
||||
const anchor = parked.current || pending.current;
|
||||
const t = e.target;
|
||||
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", restore);
|
||||
window.addEventListener("blur", restore);
|
||||
return () => {
|
||||
document.removeEventListener("pointerover", onOver);
|
||||
document.removeEventListener("pointerout", onOut);
|
||||
document.removeEventListener("pointerdown", restore);
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", restore);
|
||||
window.removeEventListener("blur", restore);
|
||||
restore();
|
||||
};
|
||||
}, [open, restore]);
|
||||
|
||||
// A control unmounted under a still cursor fires no pointerout: without this
|
||||
// its bubble stays up over whatever replaced it. The same goes for a title
|
||||
// re-rendered under it — the bubble would keep saying the old text, so it
|
||||
// hides and `restore` keeps the new attribute.
|
||||
useEffect(() => {
|
||||
if (!tip) return;
|
||||
const watch = new MutationObserver((records) => {
|
||||
const el = parked.current;
|
||||
if (!el) return;
|
||||
if (
|
||||
!el.isConnected ||
|
||||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
|
||||
)
|
||||
restore();
|
||||
});
|
||||
watch.observe(document.body, { childList: true, subtree: true });
|
||||
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
|
||||
return () => watch.disconnect();
|
||||
}, [tip, restore]);
|
||||
|
||||
// Placed after the first paint, when the bubble's real size is known —
|
||||
// measuring it is the only way to flip and clamp without guessing.
|
||||
useEffect(() => {
|
||||
if (!tip || !bubble.current) return;
|
||||
const { width, height } = bubble.current.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = window.innerHeight;
|
||||
const centre = tip.rect.left + tip.rect.width / 2;
|
||||
|
||||
let left = centre - width / 2;
|
||||
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
||||
// Below by default; above only when below would run off the screen AND
|
||||
// above actually fits, so a control at the very bottom still gets a bubble.
|
||||
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
||||
// The arrow points at the control even after the bubble was clamped.
|
||||
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
||||
above,
|
||||
});
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={bubble}
|
||||
role="tooltip"
|
||||
data-testid="global-tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
opacity: pos ? 1 : 0,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
className={TOOLTIP_SURFACE}
|
||||
>
|
||||
{tip.text}
|
||||
{/* Two borders only: a fully bordered diamond draws its other two
|
||||
edges straight across the bubble it is attached to. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
||||
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
||||
pos?.above ? "border-b border-r" : "border-l border-t"
|
||||
} border-border`}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalTooltip;
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* @file UpdateNotifier.tsx
|
||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||
* fast-forwardable branch — an "Update now" button that calls
|
||||
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||
* then polls until it's back and reloads the page.
|
||||
* @description Modal surfaced when a newer RELEASE of the dashboard has been
|
||||
* published (or, when no release can be read, when the checkout is behind its
|
||||
* remote tracking branch). Shows the release version against the running one
|
||||
* with a link to its notes, the exact terminal command with copy-to-clipboard,
|
||||
* and — when the install is fast-forwardable — an "Update now" button that
|
||||
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
|
||||
* restart the server itself, then polls until it's back and reloads the page.
|
||||
*
|
||||
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
|
||||
* the same button, wired to a different mechanism: the server hands it to a
|
||||
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
|
||||
* which pulls the new cache directory, reinstalls the runtime and restarts the
|
||||
* dashboard. It takes minutes rather than seconds, so the progress text differs
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -14,8 +25,9 @@
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
||||
* commit re-opens the prompt. Settings can reset dismissal via the
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -66,21 +78,29 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** How often to silently re-check for updates in the background. */
|
||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
||||
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
* `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`),
|
||||
* which is why it belongs here even though `applyUpdate` itself refuses it. */
|
||||
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
||||
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
||||
return (
|
||||
situation === "plugin_managed" ||
|
||||
situation === "release_available" ||
|
||||
situation === "tracking_canonical" ||
|
||||
situation === "fork_or_diverged_tracking"
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
@@ -88,7 +108,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
}
|
||||
|
||||
/** Read the last dismissed upstream SHA from `localStorage`, or null. */
|
||||
/** Read the last dismissed release tag / upstream SHA from `localStorage`. */
|
||||
function loadDismissedSha(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY);
|
||||
@@ -99,7 +119,7 @@ function loadDismissedSha(): string | null {
|
||||
|
||||
/**
|
||||
* Git update availability modal — mounted once in {@link Layout}.
|
||||
* @returns `null` when no update is available or the current SHA was dismissed.
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
@@ -161,19 +181,22 @@ export function UpdateNotifier() {
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
|
||||
const show = Boolean(
|
||||
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
|
||||
);
|
||||
// Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't
|
||||
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
|
||||
// commit the user already has. Falls back to the upstream SHA for the
|
||||
// commit-count path (no release readable).
|
||||
const dismissKey = status?.latest_release || status?.remote_sha || null;
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (restarting || !status?.remote_sha) return;
|
||||
if (restarting || !dismissKey) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||
localStorage.setItem(DISMISS_KEY, dismissKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(status.remote_sha);
|
||||
}, [restarting, status?.remote_sha]);
|
||||
setDismissedSha(dismissKey);
|
||||
}, [restarting, dismissKey]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
@@ -213,12 +236,27 @@ export function UpdateNotifier() {
|
||||
// Polls `status()` until the restarted server answers again, then reloads
|
||||
// so the tab picks up the new client bundle too — the server can't push
|
||||
// this over its own WebSocket since it's mid-restart.
|
||||
const pollUntilBack = useCallback(() => {
|
||||
/**
|
||||
* @param requireDownFirst wait for the server to actually disappear before
|
||||
* treating a success as "it's back". The git path rebuilds and restarts
|
||||
* within seconds, but the plugin chain pulls a new cache directory and
|
||||
* reinstalls dependencies first — the OLD server stays up for that whole
|
||||
* stretch, so polling naively would get a 200 from the process we are
|
||||
* waiting to replace and reload onto the same old bundle.
|
||||
*/
|
||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(() => window.location.reload())
|
||||
.catch(() => setTimeout(attempt, 1500));
|
||||
.then(() => {
|
||||
if (sawDown) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
}, []);
|
||||
@@ -232,11 +270,17 @@ export function UpdateNotifier() {
|
||||
if (result.applied) {
|
||||
setApplying(false);
|
||||
setRestarting(true);
|
||||
pollUntilBack();
|
||||
// mode "plugin": the old server lingers while the chain installs, so
|
||||
// only treat a reachable server as "back" after it has gone away once.
|
||||
pollUntilBack(result.mode === "plugin");
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
||||
result.reason === "plugin_managed"
|
||||
? t("reasonPluginManaged")
|
||||
: result.reason === "not_fast_forwardable"
|
||||
? t("reasonNotFastForwardable")
|
||||
: t("applyError")
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("applyError"));
|
||||
@@ -249,6 +293,8 @@ export function UpdateNotifier() {
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
const behind = status.commits_behind ?? 0;
|
||||
const release = status.latest_release || null;
|
||||
const pluginManaged = status.situation === "plugin_managed";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -260,7 +306,9 @@ export function UpdateNotifier() {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -272,10 +320,14 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{t("title")}
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
{/* Never print a commit count on a shallow clone — the number
|
||||
is a graft artefact, not a distance. */}
|
||||
{release && !status.shallow_clone && status.local_version
|
||||
? t("releaseSubtitle", { current: status.local_version })
|
||||
: t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +343,30 @@ export function UpdateNotifier() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
{status.latest_release_url ? (
|
||||
<a
|
||||
href={status.latest_release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("viewRelease")}
|
||||
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Release lookup failed and detection fell back to commit counting:
|
||||
say so, so "up to date" is never read as "release checked". */}
|
||||
{status.release_check && status.release_check !== "ok" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("releaseUnavailable", { reason: status.release_check })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.fetch_error ? (
|
||||
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
|
||||
@@ -299,7 +374,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!status.git_repo ? (
|
||||
{!status.git_repo && status.install_kind !== "plugin" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("notGit")}
|
||||
</div>
|
||||
@@ -323,7 +398,8 @@ export function UpdateNotifier() {
|
||||
{/* The restart hint only applies when the printed command actually
|
||||
* rewrites the working tree. Feature-branch / detached-HEAD commands
|
||||
* are fetch-only - restarting the dashboard would change nothing. */}
|
||||
{status.situation === "tracking_canonical" ||
|
||||
{status.situation === "release_available" ||
|
||||
status.situation === "tracking_canonical" ||
|
||||
status.situation === "fork_or_diverged_tracking" ? (
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||
) : null}
|
||||
@@ -331,7 +407,22 @@ export function UpdateNotifier() {
|
||||
{restarting ? (
|
||||
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||
{t("restarting")}
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* A previous chain that failed: the only place the user would ever
|
||||
learn why, since the server it would have reported from is the one
|
||||
that never came back. */}
|
||||
{status.last_plugin_update && status.last_plugin_update.ok === false ? (
|
||||
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||
{t("lastPluginUpdateFailed", {
|
||||
step: status.last_plugin_update.step,
|
||||
error: status.last_plugin_update.error || "",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -343,7 +434,10 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
|
||||
own single line; `flex-wrap` then breaks the ROW instead of the
|
||||
words if a translation still outgrows the modal. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
@@ -384,7 +478,10 @@ export function UpdateNotifier() {
|
||||
) : (
|
||||
<Zap className="w-4 h-4" aria-hidden />
|
||||
)}
|
||||
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||
{/* `restartingShort`, not `restarting`: the full sentence is
|
||||
already in the banner above, and a whole sentence on a button
|
||||
is what forced this footer onto two lines. */}
|
||||
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,19 @@ function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render the brand name", () => {
|
||||
it("should render the brand logo in place of the text subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
|
||||
// One wordmark in both themes — no light/dark variant.
|
||||
expect(screen.getByAltText("SmartGift Solution")).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("logo-main.png")
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument();
|
||||
it("should render the favicon mark when collapsed", () => {
|
||||
renderSidebar(true, true);
|
||||
const mark = screen.getByAltText("SmartGift Solution");
|
||||
expect(mark).toHaveAttribute("src", "https://smartgift.vn/favicon.ico");
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file Sidebar.theme.test.tsx
|
||||
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
function renderSidebar(collapsed = false) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Sidebar theme controls", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking System sets the mode and presses only System", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "System" }));
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Light applies light mode to <html>", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
await user.click(screen.getByRole("button", { name: "Light" }));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem("theme", "light");
|
||||
renderSidebar(true);
|
||||
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file Tooltip.test.tsx
|
||||
* @description Pins the app-wide tooltip renderer: a control's native `title`
|
||||
* becomes the designed bubble on hover and on keyboard focus, the native
|
||||
* tooltip is suppressed only while the bubble is up and the attribute is put
|
||||
* back afterwards (it is the control's accessible name), and `data-tip-off`
|
||||
* opts out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
return render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Refresh the working copy">refresh</button>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
// jsdom's :focus-visible is a heuristic over the previous events and leaks
|
||||
// between tests, so the tests say which kind of focus they mean: a spy answers
|
||||
// `:focus-visible` from this flag and passes every other selector through.
|
||||
let keyboardFocus = false;
|
||||
const realMatches = Element.prototype.matches;
|
||||
beforeEach(() => {
|
||||
keyboardFocus = false;
|
||||
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
|
||||
return sel === ":focus-visible"
|
||||
? keyboardFocus && document.activeElement === this
|
||||
: realMatches.call(this, sel);
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const tabTo = (el: HTMLElement) => {
|
||||
keyboardFocus = true;
|
||||
act(() => el.focus());
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const settle = async () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe("GlobalTooltip", () => {
|
||||
it("shows the bubble after a delay and takes the native title while it is up", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
hover(button);
|
||||
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
});
|
||||
|
||||
it("hides and gives the title back on the way out", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
unhover(button);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("opens immediately on keyboard focus and closes on Escape", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
tabTo(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
|
||||
setup(
|
||||
<button title="Raw value" data-tip-off>
|
||||
opted out
|
||||
</button>
|
||||
);
|
||||
const opted = screen.getByRole("button", { name: "opted out" });
|
||||
|
||||
hover(opted);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(opted).toHaveAttribute("title", "Raw value");
|
||||
});
|
||||
|
||||
it("reads the title through a child element, and ignores an empty one", async () => {
|
||||
setup(
|
||||
<button title="">
|
||||
<span data-testid="blank-child">blank</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
hover(screen.getByTestId("blank-child"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
button.innerHTML = "<span data-testid='deep'>refresh</span>";
|
||||
hover(screen.getByTestId("deep"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
it("keeps an icon-only control's accessible name while its title is parked", async () => {
|
||||
setup(
|
||||
<button title="Stage this file">
|
||||
<svg aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const icon = screen.getByRole("button", { name: "Stage this file" });
|
||||
tabTo(icon);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
expect(icon).not.toHaveAttribute("title");
|
||||
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Put back exactly as found: no aria-label left behind.
|
||||
expect(icon).toHaveAttribute("title", "Stage this file");
|
||||
expect(icon).not.toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("does not flicker while the pointer crosses the control's own children", async () => {
|
||||
setup(
|
||||
<button title="Two parts">
|
||||
<span data-testid="part-a">a</span>
|
||||
<span data-testid="part-b">b</span>
|
||||
</button>
|
||||
);
|
||||
const a = screen.getByTestId("part-a");
|
||||
const b = screen.getByTestId("part-b");
|
||||
hover(a);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
|
||||
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
|
||||
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
|
||||
// Still up, with no fresh delay.
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
});
|
||||
|
||||
it("hides when its control is removed from the page", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Going away">x</button>
|
||||
</>
|
||||
);
|
||||
hover(screen.getByRole("button", { name: "x" }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// A poll flips the hint while the pointer rests on the control.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Up to date">pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
// The bubble must not keep advertising the old text.
|
||||
expect(screen.queryByText("Pull 2 commits")).toBeNull();
|
||||
|
||||
unhover(button);
|
||||
expect(button).toHaveAttribute("title", "Up to date");
|
||||
});
|
||||
|
||||
it("leaves no title behind when React removed it while the bubble was up", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// React "removes" an attribute that is already parked: no MutationRecord.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button>pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
unhover(button);
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
expect(button).not.toHaveAttribute("data-tip");
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
setup(<button title="Second control">second</button>);
|
||||
// A click is pointer focus: :focus-visible does not match.
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
await user.click(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
tabTo(screen.getByRole("button", { name: "second" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
|
||||
});
|
||||
|
||||
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="scroller">
|
||||
<button title="Inside the scroller">inside</button>
|
||||
</div>
|
||||
<div data-testid="elsewhere" />
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "inside" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("elsewhere"));
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("scroller"));
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Inside the scroller");
|
||||
});
|
||||
|
||||
it("hides when the document itself scrolls", async () => {
|
||||
setup();
|
||||
hover(screen.getByRole("button", { name: "refresh" }));
|
||||
await settle();
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,95 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => {
|
||||
// The button works here via the detached `claude plugin update` chain — but
|
||||
// it moves only the dashboard server, so the note about /reload-plugins is
|
||||
// part of the promise, not decoration.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers the button when a plugin install has no git clone at all", async () => {
|
||||
// The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so
|
||||
// git_repo is legitimately false. Gating the UI on that reported "not a git
|
||||
// clone; check manually" for an install the dashboard can check AND update.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
git_repo: false,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.1",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
last_plugin_update: {
|
||||
ok: false,
|
||||
step: "resolve_claude",
|
||||
error: "the `claude` CLI is not on this server's PATH",
|
||||
finished_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(/resolve_claude/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("titles the modal with the release version when one was read", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "release_available",
|
||||
release_check: "ok",
|
||||
local_version: "1.5.0",
|
||||
latest_release: "v1.6.0",
|
||||
commits_behind_release: 3,
|
||||
commits_ahead_of_release: 0,
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
// A release IS fast-forwardable on a checkout, so the button stays.
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||
applyMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file ConfigTabBar.tsx
|
||||
* @description The tab strip both config explorers wear: one horizontally
|
||||
* scrollable row with fade edges and chevrons that appear only when there is
|
||||
* something to scroll to.
|
||||
*
|
||||
* Shared rather than copied because the two pages must look the same. Codex
|
||||
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
|
||||
* read as a different component sitting next to Claude Code's twelve-tab
|
||||
* scroller. One row that scrolls is the same shape at any tab count.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
export interface ConfigTab<K extends string> {
|
||||
key: K;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
|
||||
count: number | null;
|
||||
}
|
||||
|
||||
interface ConfigTabBarProps<K extends string> {
|
||||
tabs: ConfigTab<K>[];
|
||||
current: K;
|
||||
onSelect: (key: K) => void;
|
||||
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
|
||||
testIdPrefix: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function ConfigTabBar<K extends string>({
|
||||
tabs,
|
||||
current,
|
||||
onSelect,
|
||||
testIdPrefix,
|
||||
ariaLabel,
|
||||
}: ConfigTabBarProps<K>) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// Update scroll affordances when content size or scroll position changes.
|
||||
const updateAffordances = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 1);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateAffordances();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", updateAffordances, { passive: true });
|
||||
const ro = new ResizeObserver(updateAffordances);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", updateAffordances);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [updateAffordances]);
|
||||
|
||||
// Scroll the active tab into view when it changes (e.g. user picks a tab
|
||||
// that's offscreen, or window resize hides the active one).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
|
||||
if (!active) return;
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
if (activeRect.left < elRect.left + 8) {
|
||||
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
|
||||
} else if (activeRect.right > elRect.right - 8) {
|
||||
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
const scrollByButton = (dir: 1 | -1) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-xl border border-border bg-surface-1">
|
||||
{/* Left edge gradient + chevron */}
|
||||
<div
|
||||
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
|
||||
canScrollLeft ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scrollByButton(-1)}
|
||||
aria-label="scroll tabs left"
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{tabs.map(({ key, icon: Icon, label, count }) => {
|
||||
const active = current === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-testid={`${testIdPrefix}-${key}`}
|
||||
data-tab-active={active ? "true" : undefined}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(key)}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
|
||||
active
|
||||
? "bg-accent/15 text-accent border border-accent/30"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{label}</span>
|
||||
{count !== null && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
|
||||
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right edge gradient + chevron */}
|
||||
<div
|
||||
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
|
||||
canScrollRight ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scrollByButton(1)}
|
||||
aria-label="scroll tabs right"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file ConfirmDeleteModal.tsx
|
||||
* @description Confirmation modal for deleting a config artifact. Tool-
|
||||
* agnostic — the caller supplies the async delete action; the modal only
|
||||
* tracks the busy state and Escape-to-cancel behavior.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType } from "../../lib/api";
|
||||
|
||||
/** See EditorModal.tsx's `DefaultScope` — same reasoning: Codex instantiates
|
||||
* `TScope` as `"user" | "project"` instead of casting into Claude's wider
|
||||
* default. */
|
||||
export type ConfirmDeleteState<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
> = {
|
||||
type: TType;
|
||||
scope: TScope;
|
||||
name?: string;
|
||||
path: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
} | null;
|
||||
|
||||
interface ConfirmDeleteModalProps<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
> {
|
||||
state: NonNullable<ConfirmDeleteState<TType, TScope>>;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
>({ state, onCancel, onConfirm }: ConfirmDeleteModalProps<TType, TScope>) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-status-danger" />
|
||||
<span className="text-sm font-medium text-fg-primary flex-1">
|
||||
{t("edit.confirmDelete")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
|
||||
{t("edit.confirmDeletePath", { path: state.path })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
|
||||
>
|
||||
{t("edit.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file EditorModal.tsx
|
||||
* @description Create/edit modal for text-file config artifacts (skills,
|
||||
* agents, commands, output styles, memory). Tool-agnostic: it takes a
|
||||
* `fetchContent` callback instead of reaching for any particular tool's API,
|
||||
* so the same modal serves both the Claude Code and Codex config pages.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, X, Save, AlertCircle } from "lucide-react";
|
||||
import type { CcArtifactType } from "../../lib/api";
|
||||
|
||||
/** Claude Code's own scope union, kept as the default so existing callers
|
||||
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
|
||||
* its caller instantiates `TScope` as just `"user" | "project"` instead of
|
||||
* casting its own narrower type into this one. */
|
||||
type DefaultScope = "user" | "project" | "auto-memory";
|
||||
|
||||
export type EditorState<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
> =
|
||||
| {
|
||||
mode: "create";
|
||||
type: TType;
|
||||
defaultScope: "user" | "project";
|
||||
template: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
}
|
||||
| {
|
||||
mode: "edit";
|
||||
type: TType;
|
||||
scope: TScope;
|
||||
name: string;
|
||||
filePath: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
}
|
||||
| null;
|
||||
|
||||
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
|
||||
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
|
||||
const DEFAULT_NAMELESS_TYPES = ["memory"];
|
||||
|
||||
interface EditorModalProps<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
> {
|
||||
state: NonNullable<EditorState<TType, TScope>>;
|
||||
fetchContent: (filePath: string) => Promise<string>;
|
||||
onClose: () => void;
|
||||
onSave: (args: {
|
||||
type: TType;
|
||||
targetScope: TScope;
|
||||
name: string | undefined;
|
||||
content: string;
|
||||
project?: string;
|
||||
}) => Promise<void>;
|
||||
/** Which `state.type` values are nameless singleton files. Defaults to
|
||||
* Claude Code's own `["memory"]` so existing callers are unaffected. */
|
||||
namelessTypes?: readonly string[];
|
||||
}
|
||||
|
||||
export function EditorModal<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
>({
|
||||
state,
|
||||
fetchContent,
|
||||
onClose,
|
||||
onSave,
|
||||
namelessTypes = DEFAULT_NAMELESS_TYPES,
|
||||
}: EditorModalProps<TType, TScope>) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const isCreate = state.mode === "create";
|
||||
const isNameless = namelessTypes.includes(state.type);
|
||||
const isAutoMemory = state.type === "auto-memory";
|
||||
const [content, setContent] = useState<string>(isCreate ? state.template : "");
|
||||
const [name, setName] = useState<string>("");
|
||||
const [targetScope, setTargetScope] = useState<"user" | "project">(
|
||||
isCreate
|
||||
? state.defaultScope
|
||||
: state.scope === "auto-memory"
|
||||
? "user"
|
||||
: (state.scope as "user" | "project")
|
||||
);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// For edit mode, fetch the actual file content
|
||||
useEffect(() => {
|
||||
if (state.mode === "edit") {
|
||||
setLoading(true);
|
||||
fetchContent(state.filePath)
|
||||
.then((text) => {
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [state, fetchContent]);
|
||||
|
||||
// Esc to close
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!isNameless && state.mode === "create" && !name) {
|
||||
setError(t("edit.nameLabel"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
// For auto-memory creates, append a .md extension when the user omits it.
|
||||
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
|
||||
const effectiveName =
|
||||
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
|
||||
await onSave({
|
||||
type: state.type,
|
||||
// Widening back to TScope: this component is the one place that
|
||||
// legitimately knows about all three scope shapes across both
|
||||
// callers; a Codex caller's TScope ("user" | "project") never
|
||||
// actually receives "auto-memory" since isAutoMemory can only be
|
||||
// true when TType includes "auto-memory" (Claude-only).
|
||||
targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope,
|
||||
name: effectiveName,
|
||||
content,
|
||||
project: state.project,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setError(t("edit.writeError", { message: msg }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
|
||||
|
||||
const titleText = isCreate
|
||||
? isAutoMemory
|
||||
? t("memory.newFileTitle", { project: state.project ?? "" })
|
||||
: t("edit.newTitle", { type: state.type })
|
||||
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<Pencil className="w-4 h-4 text-fg-muted" />
|
||||
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{isCreate && !isNameless && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={
|
||||
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
|
||||
}
|
||||
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoMemory ? (
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("memory.projectLabel")}
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
|
||||
{state.project}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.scopePicker")}
|
||||
</label>
|
||||
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{(["user", "project"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setTargetScope(s)}
|
||||
className={`px-3 py-1 text-[11px] font-medium rounded ${
|
||||
targetScope === s
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`scope.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.contentLabel")}
|
||||
</label>
|
||||
{loading ? (
|
||||
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
|
||||
) : (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
|
||||
>
|
||||
{t("edit.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{saving ? t("edit.saving") : t("edit.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file FileViewer.tsx
|
||||
* @description Read-only file-content modal for the config explorer. Takes
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, X, AlertCircle } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: { path: string; data: CcFileResponse | null; error: string | null };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<FileText className="w-4 h-4 text-fg-muted" />
|
||||
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
||||
{state.path}
|
||||
</span>
|
||||
<CopyButton value={state.path} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto p-4">
|
||||
{state.error ? (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{state.error}
|
||||
</div>
|
||||
) : !state.data ? (
|
||||
<div className="text-sm text-fg-muted">…</div>
|
||||
) : (
|
||||
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
||||
{state.data.text}
|
||||
{state.data.truncated && (
|
||||
<span className="text-fg-muted italic">
|
||||
{"\n\n"}
|
||||
{t("common.truncated")}
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file MdItemList.tsx
|
||||
* @description Generic list + card for markdown-frontmatter config artifacts
|
||||
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
||||
* items and the open/edit/delete handlers, nothing here reaches for an API.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
||||
import { ScopeBadge } from "./ScopeBadge";
|
||||
import { Empty, SkeletonRows } from "./configPrimitives";
|
||||
|
||||
interface MdItemListProps {
|
||||
items: CcMdItem[] | null;
|
||||
search: string;
|
||||
onOpen: (path: string) => void;
|
||||
onEdit: (
|
||||
type: CcArtifactType,
|
||||
item: { scope: "user" | "project"; name: string; filePath: string }
|
||||
) => void;
|
||||
onDelete: (
|
||||
type: CcArtifactType,
|
||||
scope: "user" | "project",
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return null;
|
||||
const q = search.toLowerCase();
|
||||
return items.filter((it) => {
|
||||
if (!q) return true;
|
||||
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return blob.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
if (!filtered) return <SkeletonRows n={6} />;
|
||||
if (filtered.length === 0) return <Empty />;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((it) => (
|
||||
<MdItemCard
|
||||
key={`${it.scope}:${it.name}`}
|
||||
item={it}
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
kind={kind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MdItemCardProps {
|
||||
item: CcMdItem;
|
||||
onOpen: (p: string) => void;
|
||||
onEdit: (
|
||||
type: CcArtifactType,
|
||||
item: { scope: "user" | "project"; name: string; filePath: string }
|
||||
) => void;
|
||||
onDelete: (
|
||||
type: CcArtifactType,
|
||||
scope: "user" | "project",
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
|
||||
|
||||
const filePath = item.file || `${item.path}/SKILL.md`;
|
||||
const description =
|
||||
item.frontmatter.description ||
|
||||
item.preview
|
||||
.replace(/^#+\s.*\n/, "")
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
|
||||
<ScopeBadge scope={item.scope} />
|
||||
{item.frontmatter.model && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
|
||||
{item.frontmatter.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{kind === "agents" && item.frontmatter.tools && (
|
||||
<div className="mt-2 text-[11px] text-fg-muted">
|
||||
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
|
||||
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onOpen(filePath)}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t("common.viewSource")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t("edit.editButton")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("edit.deleteButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file ProjectMemoryModal.tsx
|
||||
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
|
||||
* directory that is not the dashboard's own. Shared by the Claude Code and
|
||||
* Codex config pages; the caller supplies its i18n namespace and the create
|
||||
* call, so this component knows about neither tool.
|
||||
*
|
||||
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
|
||||
* offered as suggestions on a plain text input rather than a closed dropdown:
|
||||
* a project you have never run through the dashboard is exactly the case this
|
||||
* modal exists for, so it must stay typeable.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
|
||||
import { api, type CwdSuggestion } from "../../lib/api";
|
||||
|
||||
interface ProjectMemoryModalProps {
|
||||
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
|
||||
ns: string;
|
||||
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
|
||||
filename: string;
|
||||
/** Starting body text, so the user edits a skeleton instead of a blank box. */
|
||||
template: string;
|
||||
onClose: () => void;
|
||||
/** Resolves with the path actually written (the server realpaths the dir). */
|
||||
onCreate: (args: { dir: string; content: string }) => Promise<string>;
|
||||
}
|
||||
|
||||
export function ProjectMemoryModal({
|
||||
ns,
|
||||
filename,
|
||||
template,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: ProjectMemoryModalProps) {
|
||||
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
|
||||
// component never depends on the calling page's other key groups lining up.
|
||||
const { t } = useTranslation(ns);
|
||||
const listId = useId();
|
||||
const [dir, setDir] = useState("");
|
||||
const [content, setContent] = useState(template);
|
||||
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => setSuggestions(r.items))
|
||||
// Suggestions are a convenience; the input still works without them.
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const target = dir.trim();
|
||||
if (!target) {
|
||||
setError(t("projectMemory.dirRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate({ dir: target, content });
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [dir, content, onCreate, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("projectMemory.title", { file: filename })}
|
||||
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<BookOpen className="w-4 h-4 text-fg-muted" />
|
||||
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
|
||||
{t("projectMemory.title", { file: filename })}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("projectMemory.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${listId}-dir`}
|
||||
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
{t("projectMemory.dirLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`${listId}-dir`}
|
||||
list={listId}
|
||||
value={dir}
|
||||
onChange={(e) => setDir(e.target.value)}
|
||||
placeholder="/home/you/projects/my-app"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{suggestions.map((s) => (
|
||||
<option key={s.path} value={s.path}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{t("projectMemory.dirHelp", { file: filename })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${listId}-content`}
|
||||
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
{t("projectMemory.contentLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`${listId}-content`}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
|
||||
>
|
||||
{t("projectMemory.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file ScopeBadge.tsx
|
||||
* @description Small colored badge for a config artifact's scope (user /
|
||||
* project / project-local). Tool-agnostic — takes a plain string so it works
|
||||
* for Claude Code and Codex scopes alike.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ScopeBadge({ scope }: { scope: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const color =
|
||||
scope === "user"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: scope === "project"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: scope === "project-local"
|
||||
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border";
|
||||
const label =
|
||||
scope === "project-local"
|
||||
? t("scope.projectLocal")
|
||||
: scope === "user"
|
||||
? t("scope.user")
|
||||
: scope === "project"
|
||||
? t("scope.project")
|
||||
: scope;
|
||||
return (
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file ToastNotice.tsx
|
||||
* @description Bottom-right success/error toast for the config explorer.
|
||||
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
|
||||
* message down.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { AlertCircle, Check, X } from "lucide-react";
|
||||
|
||||
export type Toast = { kind: "success" | "error"; message: string } | null;
|
||||
|
||||
export function ToastNotice({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: NonNullable<Toast>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isErr = toast.kind === "error";
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-md">
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
|
||||
isErr
|
||||
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
||||
: "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{isErr ? (
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-current/70 hover:text-current p-0.5"
|
||||
aria-label="dismiss"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file EditorModal.test.tsx
|
||||
* @description Unit tests for the tool-agnostic `EditorModal`: it must load
|
||||
* existing content through the injected `fetchContent` callback rather than
|
||||
* a hardcoded API, and must refuse to save a create with no name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorModal } from "../EditorModal";
|
||||
|
||||
describe("EditorModal", () => {
|
||||
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
|
||||
const fetchContent = vi.fn().mockResolvedValue("old body");
|
||||
render(
|
||||
<EditorModal
|
||||
state={{
|
||||
mode: "edit",
|
||||
type: "rules",
|
||||
scope: "user",
|
||||
name: "default",
|
||||
filePath: "/r/default.rules",
|
||||
}}
|
||||
fetchContent={fetchContent}
|
||||
onClose={() => {}}
|
||||
onSave={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
|
||||
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to save a create with no name", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<EditorModal
|
||||
state={{ mode: "create", type: "agents", defaultScope: "user", template: "" }}
|
||||
fetchContent={async () => ""}
|
||||
onClose={() => {}}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file ProjectMemoryModal.test.tsx
|
||||
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
|
||||
* the user typed (not a suggestion it picked for them), must refuse to submit
|
||||
* an empty one, and must render its labels from whichever namespace the
|
||||
* calling page passes — the Codex page shares this component with the Claude
|
||||
* page, and a key group that only exists in `ccConfig` would render raw.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ProjectMemoryModal } from "../ProjectMemoryModal";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { cwds: vi.fn() } },
|
||||
}));
|
||||
|
||||
const cwds = vi.mocked(api.run.cwds);
|
||||
|
||||
beforeEach(() => {
|
||||
cwds.mockResolvedValue({
|
||||
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
|
||||
});
|
||||
});
|
||||
|
||||
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
|
||||
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
|
||||
render(
|
||||
<ProjectMemoryModal
|
||||
ns="ccConfig"
|
||||
filename="CLAUDE.md"
|
||||
template="# Project memory"
|
||||
onClose={() => {}}
|
||||
onCreate={onCreate}
|
||||
{...over}
|
||||
/>
|
||||
);
|
||||
return { onCreate };
|
||||
}
|
||||
|
||||
describe("ProjectMemoryModal", () => {
|
||||
it("sends the typed directory and the edited content", async () => {
|
||||
const { onCreate } = renderModal();
|
||||
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
|
||||
const body = screen.getByLabelText(/content/i);
|
||||
await userEvent.clear(body);
|
||||
await userEvent.type(body, "hello");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
|
||||
});
|
||||
|
||||
it("refuses to submit without a directory", async () => {
|
||||
const { onCreate } = renderModal();
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
|
||||
renderModal();
|
||||
// A datalist suggests; it does not constrain. A project the dashboard has
|
||||
// never run in is precisely this modal's reason to exist, so the input
|
||||
// stays free text.
|
||||
const input = screen.getByLabelText(/project directory/i);
|
||||
expect(input.tagName).toBe("INPUT");
|
||||
// jsdom exposes no accessible role for datalist options, so query the DOM.
|
||||
await vi.waitFor(() => {
|
||||
const list = document.getElementById(input.getAttribute("list")!);
|
||||
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders from the Codex namespace too", async () => {
|
||||
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
|
||||
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
|
||||
// A missing key renders as the bare key path — catch that directly.
|
||||
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's rejection instead of closing", async () => {
|
||||
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
|
||||
render(
|
||||
<ProjectMemoryModal
|
||||
ns="ccConfig"
|
||||
filename="CLAUDE.md"
|
||||
template=""
|
||||
onClose={() => {}}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file configPrimitives.tsx
|
||||
* @description Small tool-agnostic atoms shared by the config-explorer UI
|
||||
* (Claude Code today, Codex next): an empty-state card, a skeleton-row list
|
||||
* placeholder, a copy-to-clipboard button, and a byte-size formatter. None
|
||||
* of these know about any particular tool's API.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||
title={t("common.copyPath")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("common.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRows({ n }: { n: number }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={linkM[2]!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</a>
|
||||
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
||||
<ul
|
||||
key={idx}
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
|
||||
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
|
||||
label: "User",
|
||||
icon: User,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
|
||||
accentBar: "before:bg-blue-600/40",
|
||||
headerText: "text-blue-300",
|
||||
headerText: "text-blue-700 dark:text-blue-300",
|
||||
},
|
||||
assistant: {
|
||||
label: "Assistant",
|
||||
icon: Bot,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
|
||||
accentBar: "before:bg-violet-500/40",
|
||||
headerText: "text-violet-200",
|
||||
headerText: "text-violet-800 dark:text-violet-200",
|
||||
},
|
||||
orchestrator: {
|
||||
label: "Main agent",
|
||||
icon: Workflow,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
|
||||
accentBar: "before:bg-teal-500/40",
|
||||
headerText: "text-teal-200",
|
||||
headerText: "text-teal-800 dark:text-teal-200",
|
||||
},
|
||||
system: {
|
||||
label: "System",
|
||||
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
||||
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
||||
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
|
||||
<span className="text-fg-muted">Renamed session →</span>
|
||||
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
|
||||
{timestamp && (
|
||||
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-300/80"
|
||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
||||
/>
|
||||
);
|
||||
case "text": {
|
||||
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
<span className="text-orange-700/70 dark:text-orange-300/70">
|
||||
↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="text-cyan-400/80"
|
||||
textClass="text-cyan-700/80 dark:text-cyan-400/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
||||
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<span className="font-mono">{obj.file_path}</span>
|
||||
{obj.replace_all === true && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
|
||||
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
if (tool === "read" && typeof obj.file_path === "string") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
||||
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
|
||||
<span className="font-mono break-all">{obj.file_path}</span>
|
||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
||||
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Pattern
|
||||
</span>
|
||||
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{obj.pattern}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,8 @@ export interface ToolStyle {
|
||||
|
||||
const VIOLET: ToolStyle = {
|
||||
Icon: Wrench,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
};
|
||||
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
|
||||
const STYLES: Record<string, ToolStyle> = {
|
||||
bash: {
|
||||
Icon: Terminal,
|
||||
text: "text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-300",
|
||||
text: "text-emerald-700 dark:text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
||||
bar: "bg-emerald-500/60",
|
||||
border: "border-emerald-500/20",
|
||||
},
|
||||
read: {
|
||||
Icon: FileText,
|
||||
text: "text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
bar: "bg-sky-500/60",
|
||||
border: "border-sky-500/20",
|
||||
},
|
||||
write: {
|
||||
Icon: FilePlus2,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
},
|
||||
edit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
multiedit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
grep: {
|
||||
Icon: Search,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
glob: {
|
||||
Icon: FolderTree,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
webfetch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
websearch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
task: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
agent: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
todowrite: {
|
||||
Icon: ListTodo,
|
||||
text: "text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-300",
|
||||
text: "text-rose-700 dark:text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
||||
bar: "bg-rose-500/60",
|
||||
border: "border-rose-500/20",
|
||||
},
|
||||
schedulewakeup: {
|
||||
Icon: Clock,
|
||||
text: "text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
|
||||
bar: "bg-orange-500/60",
|
||||
border: "border-orange-500/20",
|
||||
},
|
||||
skill: {
|
||||
Icon: Sparkles,
|
||||
text: "text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-300",
|
||||
text: "text-fuchsia-700 dark:text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
|
||||
bar: "bg-fuchsia-500/60",
|
||||
border: "border-fuchsia-500/20",
|
||||
},
|
||||
|
||||
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
let newLine = hunk.newStart;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
<table className="w-full border-collapse">
|
||||
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
const showNew = kind !== "remove";
|
||||
const rowBg =
|
||||
kind === "add"
|
||||
? "bg-green-500/10 text-green-200"
|
||||
? "bg-green-500/10 text-green-800 dark:text-green-200"
|
||||
: kind === "remove"
|
||||
? "bg-status-danger/10 text-status-danger"
|
||||
: "text-fg-secondary";
|
||||
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||
value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
|
||||
}`}
|
||||
>
|
||||
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||
{matches.map((m, i) => (
|
||||
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
|
||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
|
||||
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
|
||||
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
|
||||
</li>
|
||||
|
||||
@@ -24,8 +24,8 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool } from "../../lib/types";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -77,11 +77,16 @@ export function AddLaneModal({
|
||||
onClose,
|
||||
onAdded,
|
||||
cwdSuggestions,
|
||||
prefill,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||
onAdded: (lane: Lane) => void;
|
||||
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
|
||||
* A branch here means the sibling worktree checks out THAT branch, which
|
||||
* is the existing-branch mode — the reason that mode exists (issue #34). */
|
||||
prefill?: { sourceRepo?: string; branch?: string } | null;
|
||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
@@ -92,9 +97,17 @@ export function AddLaneModal({
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
// Two ways to start a worktree lane, and git's own rule decides which is
|
||||
// possible: "new" forks a branch off `base`, "existing" checks out a branch
|
||||
// that is already there — which git allows exactly once across all of a
|
||||
// repo's worktrees, hence the busy markers below.
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -104,6 +117,7 @@ export function AddLaneModal({
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
@@ -112,8 +126,11 @@ export function AddLaneModal({
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
@@ -121,22 +138,42 @@ export function AddLaneModal({
|
||||
setSetupResult(null);
|
||||
};
|
||||
|
||||
// The template a lane is created with is the ONLY chance to get it right
|
||||
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||
// Fetched on open (templates are file-backed and can change between opens).
|
||||
// A prefill only ever seeds the fields ON OPEN: the user may then change
|
||||
// anything, and re-applying it on every render would fight them for the box.
|
||||
useEffect(() => {
|
||||
if (!open || !prefill) return;
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.branch) {
|
||||
setBranch(prefill.branch);
|
||||
setBranchMode("existing");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// A session that boots on the vendor's API keeps talking to it for its whole
|
||||
// life, so the router has to be picked here, before the lane exists.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
api.lanes
|
||||
.pipelines()
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (!cancelled) setPipelines(r.pipelines);
|
||||
if (cancelled) return;
|
||||
setRouters(r.items);
|
||||
// A profile deleted in another tab while this modal sat open would
|
||||
// otherwise stay selected in state while the select showed no matching
|
||||
// option — and submit would send a name the server no longer knows.
|
||||
setRouter((current) =>
|
||||
current === "native" || r.items.some((item) => item.name === current) ? current : "native"
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: the select just falls back to the single `default` option
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
// Quiet: with no list the radio simply offers Native, and the lane is
|
||||
// still created — a router is an optional extra, never a precondition.
|
||||
if (!cancelled) setRouters([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRoutersLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -153,6 +190,7 @@ export function AddLaneModal({
|
||||
const path = sourceRepo.trim();
|
||||
if (!path) {
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(null);
|
||||
return;
|
||||
@@ -163,6 +201,7 @@ export function AddLaneModal({
|
||||
const r = await api.lanes.branches(path);
|
||||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
setBase(r.current || r.branches[0] || "");
|
||||
setBranchesError(null);
|
||||
} catch {
|
||||
@@ -170,6 +209,7 @@ export function AddLaneModal({
|
||||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(t("addLaneNotARepo"));
|
||||
}
|
||||
@@ -177,6 +217,10 @@ export function AddLaneModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, sourceRepo, t]);
|
||||
|
||||
// Native is the built-in profile, so this is one call either way — there is
|
||||
// no "no router" branch to keep in step with the real one.
|
||||
const applyRouter = (laneId: number) => api.lanes.routerApply(laneId, router);
|
||||
|
||||
const submit = async () => {
|
||||
const repo = sourceRepo.trim();
|
||||
const name = title.trim();
|
||||
@@ -189,10 +233,40 @@ export function AddLaneModal({
|
||||
const result = await api.lanes.ensure({
|
||||
cwd: repo,
|
||||
title: name || undefined,
|
||||
pipeline,
|
||||
tool,
|
||||
// THIS directory, not a lane on a parent of it (#42).
|
||||
exact: true,
|
||||
});
|
||||
// Report the lane the INSTANT it exists server-side. Everything after
|
||||
// this is post-creation setup that can fail on its own, and letting one
|
||||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||||
// the list until some unrelated refetch happened to pick it up.
|
||||
// Worktree mode below already works this way.
|
||||
onAdded(result.lane);
|
||||
// Nothing was created: closing now reads as success while no new card
|
||||
// appears (#42). Say which lane already owns the folder instead.
|
||||
if (!result.created) {
|
||||
setError(
|
||||
t("addLaneExists", {
|
||||
id: result.lane.id,
|
||||
title: result.lane.title || result.lane.cwd,
|
||||
})
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// A session that booted on the vendor's API would keep talking to it
|
||||
// for its whole life, so the router is applied before anything runs.
|
||||
try {
|
||||
await applyRouter(result.lane.id);
|
||||
} catch (routerErr) {
|
||||
// The lane exists and is already listed — name what did not finish
|
||||
// instead of closing on a half-set-up lane.
|
||||
setError(routerErr instanceof Error ? routerErr.message : String(routerErr));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -202,14 +276,17 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branches || !name || !branch.trim()) return;
|
||||
// A blank branch in "new" mode is legal and deliberate: the server names
|
||||
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
|
||||
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
|
||||
try {
|
||||
const result = await api.lanes.worktree({
|
||||
sourceRepo: repo,
|
||||
title: name,
|
||||
base: base || undefined,
|
||||
branch: branch.trim(),
|
||||
pipeline,
|
||||
// An existing branch is checked out as it stands; `base` only means
|
||||
// something when a branch is being forked.
|
||||
base: branchMode === "new" ? base || undefined : undefined,
|
||||
branch: branch.trim() || undefined,
|
||||
tool,
|
||||
});
|
||||
onAdded(result.lane);
|
||||
@@ -226,12 +303,13 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
setSetupResult({
|
||||
const outcome = {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled"
|
||||
? profileOutcome.value.scaffolded
|
||||
@@ -240,7 +318,11 @@ export function AddLaneModal({
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
});
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
} as const;
|
||||
const anyFailed = Object.values(outcome).includes("failed");
|
||||
setSetupResult(outcome);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
@@ -248,13 +330,23 @@ export function AddLaneModal({
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
router: routerOutcome.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Leave the modal open so the setup summary below stays on screen; the
|
||||
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||
// than racing a timer that can close before they've looked at it.
|
||||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||||
// dialog is done — leaving it up read as "still working" (the confirm
|
||||
// button is dead from here, since the lane already exists) with no
|
||||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||||
// and then the confirm button becomes a plain close, because that is the
|
||||
// one case where the user has something to read before dismissing it.
|
||||
setBusy(false);
|
||||
if (!anyFailed) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
@@ -279,21 +371,33 @@ export function AddLaneModal({
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
// the confirm button has no second creation to run: it closes instead. That
|
||||
// is the difference between a dialog that is finished and one that is stuck.
|
||||
const done = !!setupResult;
|
||||
const disabled =
|
||||
!!setupResult ||
|
||||
!sourceRepo.trim() ||
|
||||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
const busyBranches = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={t("add")}
|
||||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onConfirm={submit}
|
||||
onConfirm={done ? handleCancel : submit}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -356,6 +460,37 @@ export function AddLaneModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
{mode === "worktree" && worktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="add-lane-worktrees"
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
>
|
||||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{worktrees.slice(0, 8).map((w) => (
|
||||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||||
<span className="shrink-0 font-mono text-fg-primary">
|
||||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted" title={w.path}>
|
||||
{w.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{worktrees.length > 8 && (
|
||||
<li className="text-[10px] text-fg-muted">
|
||||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
@@ -365,74 +500,224 @@ export function AddLaneModal({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||
{t("addLanePipelineLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="add-lane-pipeline"
|
||||
value={pipeline}
|
||||
onChange={(e) => setPipeline(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||
(p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={branchMode === "new"}
|
||||
label={t("addLaneBranchModeNew")}
|
||||
onClick={() => {
|
||||
setBranchMode("new");
|
||||
setBranch("");
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={branchMode === "existing"}
|
||||
label={t("addLaneBranchModeExisting")}
|
||||
onClick={() => {
|
||||
setBranchMode("existing");
|
||||
// Land on something usable rather than on an empty select
|
||||
// that looks like a choice was already made.
|
||||
setBranch((current) =>
|
||||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* The third answer to "which branch": none. A small or personal
|
||||
project usually wants the lane to BE the repo, working on
|
||||
whatever is checked out (`main`) — which is exactly Repo
|
||||
mode, so this jumps there rather than inventing a second way
|
||||
to do it. git cannot check `main` out in a second worktree
|
||||
while the repo itself holds it, so a worktree lane could not
|
||||
offer this even if it wanted to. */}
|
||||
<Seg
|
||||
active={false}
|
||||
label={t("addLaneBranchModeCurrent")}
|
||||
title={t("addLaneBranchModeCurrentHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchMode === "new" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
|
||||
{t("addLaneBranchExistingLabel")}
|
||||
</label>
|
||||
{freeBranches.length === 0 ? (
|
||||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
return (
|
||||
// Disabled rather than hidden: "why is my branch not
|
||||
// in the list" is a worse question than seeing it
|
||||
// greyed out with the worktree that holds it.
|
||||
<option key={b} value={b} disabled={!!busyAt}>
|
||||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The router is the one per-lane extra this form still asks for, so it
|
||||
sits in the form itself — a disclosure around a single field only
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active =
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
// The input only goes visually hidden, so keyboard and screen
|
||||
// readers still get a real radio group, and the card carries
|
||||
// its focus ring.
|
||||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||
: active
|
||||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
@@ -445,6 +730,11 @@ export function AddLaneModal({
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
ok: setupResult.router !== "failed",
|
||||
skipped: setupResult.router === "skipped",
|
||||
},
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
|
||||
@@ -123,16 +123,15 @@ export function DestructiveLaneModal({
|
||||
|
||||
const blocked = blockingReason(preflight);
|
||||
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
|
||||
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
|
||||
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
|
||||
// commits. Never offered alongside a hard blocker, where confirming is
|
||||
// for `reset` alone. `remove` forgets the lane's row and deletes nothing on
|
||||
// disk — no worktree, no branch, no database — so there is no unpushed work it
|
||||
// could discard. Never offered alongside a hard blocker, where confirming is
|
||||
// impossible anyway.
|
||||
const requiresForce =
|
||||
!blocked &&
|
||||
preflight !== null &&
|
||||
preflight.action !== "purge" &&
|
||||
preflight.blocked.includes("unpushed-commits") &&
|
||||
(preflight.action === "reset" || preflight.kind === "managed");
|
||||
preflight.action === "reset" &&
|
||||
preflight.blocked.includes("unpushed-commits");
|
||||
const disabled =
|
||||
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
|
||||
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
|
||||
|
||||
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
|
||||
@@ -1,77 +1,48 @@
|
||||
/**
|
||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
* NOT repeated here — this card only ever renders inside the Workspace
|
||||
* lane-detail section, whose header above it already shows them. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||
* lane-detail section, whose header above it already shows them. Neither is
|
||||
* progress: the pipeline map directly below this card draws the same number
|
||||
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||
* second telling of it. The repo
|
||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||
* tooling dropdown (`LaneToolsMenu`, exported from this file — agents
|
||||
* install / MCP sync / dev-sync check / create-update readme) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
* card's own scroll area.
|
||||
* card's own scroll area. The working copy — branch, uncommitted counts, the
|
||||
* git action bar — is not here either: it heads the Changes panel
|
||||
* (`LaneGitHeader`), beside the diff it describes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||
* because a stack dying is exactly what the user needs to see, and slower than
|
||||
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
||||
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
||||
* the lane poll because each call opens a socket per declared port. */
|
||||
const RUNTIME_REFRESH_MS = 10_000;
|
||||
|
||||
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||
* as the git facts (slow, since lock state changes infrequently). */
|
||||
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a card that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId]);
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own application stack. Same shape and same silence as the git
|
||||
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* is the common case and not an error worth a banner.
|
||||
*
|
||||
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||
@@ -134,38 +105,156 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||
/**
|
||||
* Which AI Router this lane runs against, and the one place it can be changed
|
||||
* after the lane exists — the Add-lane modal is the only other one, and a lane
|
||||
* outlives the moment it was created.
|
||||
*
|
||||
* Applying writes the lane's own config on disk as well as the DB column, so it
|
||||
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
|
||||
* the select back: the row must not claim a router the lane did not get.
|
||||
*
|
||||
* Rendered only when there is something to choose. A user who has never opened
|
||||
* the AI Router tab has exactly the built-in `native` profile, and a select
|
||||
* with one option is a control that cannot do anything.
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
|
||||
|
||||
function useLaneIntegrations(
|
||||
laneId: number,
|
||||
available: boolean
|
||||
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
||||
// was reset to native by a profile delete, say) has to land here too.
|
||||
useEffect(() => {
|
||||
setCurrent(lane.router_profile || "native");
|
||||
setNote(null);
|
||||
}, [lane.id, lane.router_profile]);
|
||||
|
||||
// One instance of this row serves every lane — it lives in the Workspace's
|
||||
// lane-detail header, and switching lanes swaps its `lane` prop rather than
|
||||
// remounting it. So an apply that is still in flight when the user moves on
|
||||
// would land its rollback and its note on whatever lane is showing by then:
|
||||
// lane B's failed switch putting lane B's old selection into lane A's picker.
|
||||
const shownLane = useRef(lane.id);
|
||||
useEffect(() => {
|
||||
shownLane.current = lane.id;
|
||||
}, [lane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||
.then((results) => {
|
||||
if (!alive) return;
|
||||
const next: Record<string, boolean> = {};
|
||||
INTEGRATION_NAMES.forEach((name, i) => {
|
||||
next[name] = results[i]?.enabled ?? false;
|
||||
});
|
||||
setState(next);
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setState(null);
|
||||
// Silent, same contract as the runtime poller above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [laneId, available]);
|
||||
}, []);
|
||||
|
||||
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||
if (!routers || routers.length <= 1) return null;
|
||||
|
||||
const change = async (name: string) => {
|
||||
const laneId = lane.id;
|
||||
const previous = current;
|
||||
setCurrent(name);
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await api.lanes.routerApply(laneId, name);
|
||||
if (shownLane.current !== laneId) return;
|
||||
// A live pane adopted the new base URL/key but kept the model names it
|
||||
// resolved at startup, so it would send the OLD router's model to the NEW
|
||||
// endpoint and fail with "model not found". Only that case gets the
|
||||
// warning and the restart button.
|
||||
setNote(
|
||||
res.restart_required && res.run_id
|
||||
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
|
||||
: { ok: true, text: t("router.applied") }
|
||||
);
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setCurrent(previous);
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
// `busy` belongs to the control on screen, not to the request, so it is
|
||||
// cleared either way — a lane switch must never leave the picker locked.
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill the pane and open a fresh one for this lane. Started with the lane id
|
||||
* alone on purpose: the previous run's `--model` flag names a model of the
|
||||
* router the user just left, so replaying it would re-create the exact
|
||||
* mismatch this button exists to clear.
|
||||
*/
|
||||
const restart = async (runId: string) => {
|
||||
const laneId = lane.id;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.run.kill(runId);
|
||||
await api.run.start({ laneId });
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.restarted") });
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
data-testid={`lane-router-${lane.id}`}
|
||||
>
|
||||
{t("router.label")}
|
||||
<select
|
||||
value={current}
|
||||
disabled={busy}
|
||||
onChange={(e) => void change(e.target.value)}
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* The applied-note is a tooltip, not a line of its own: it fires on a
|
||||
click the user just made, and a wrapped sentence under the control
|
||||
would push every row below it down for the life of the selection.
|
||||
A failure still gets visible colour, because that one is not
|
||||
self-evident from the select. */}
|
||||
{note && (
|
||||
<span className={note.ok ? "text-fg-muted" : "text-status-danger"} title={note.text}>
|
||||
{note.ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
)}
|
||||
{/* The one case with an action attached: restarting the pane is what
|
||||
actually makes the switch take effect, so it is a button and not
|
||||
another sentence the user has to act on somewhere else. */}
|
||||
{note?.runId && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restart(note.runId as string)}
|
||||
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{t("router.restart")}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
@@ -182,7 +271,8 @@ export function since(sec: number | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check) for one lane,
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check, and the
|
||||
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
|
||||
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||
* own runtime state independently of LaneCard's, same pattern as the
|
||||
@@ -191,14 +281,14 @@ export function since(sec: number | null): string {
|
||||
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||
null
|
||||
);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<
|
||||
"agents" | "mcp" | "sync-check" | "docs" | null
|
||||
>(null);
|
||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, 0);
|
||||
|
||||
const runLaneAction = async (
|
||||
which: "agents" | "mcp" | "sync-check",
|
||||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||||
fn: () => Promise<string>
|
||||
) => {
|
||||
setLaneActionBusy(which);
|
||||
@@ -218,6 +308,19 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||
});
|
||||
|
||||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||||
// to call: this opens the lane's own terminal run with the slash command as
|
||||
// its first message. Claude-only — codex has no slash commands.
|
||||
const handleWriteDocs = () =>
|
||||
runLaneAction("docs", async () => {
|
||||
await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
});
|
||||
return t("actions.writeDocsResult");
|
||||
});
|
||||
|
||||
const handleMcpSync = () =>
|
||||
runLaneAction("mcp", async () => {
|
||||
const result = await api.lanes.mcpSync(lane.id);
|
||||
@@ -273,6 +376,22 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
>
|
||||
{t("actions.agentsInstall")}
|
||||
</button>
|
||||
{lane.tool !== "codex" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-write-docs"
|
||||
title={t("actions.writeDocsHint")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToolsMenuOpen(false);
|
||||
void handleWriteDocs();
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("actions.writeDocs")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -333,10 +452,8 @@ export default function LaneCard({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||
|
||||
/**
|
||||
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||
@@ -363,25 +480,10 @@ export default function LaneCard({
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
// Every row below is conditional: a lane with nothing to report
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
>
|
||||
{/* Progress line: how far through the pipeline. The stage's own name
|
||||
and how long it's been running are already in the Workspace
|
||||
header above; not repeated here. Omitted at 0% — an empty bar
|
||||
is not information, just a blank row above a dead/idle lane. */}
|
||||
{lane.progress > 0 && (
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
data-testid="lane-progress-fill"
|
||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
||||
style={{ width: `${lane.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
@@ -397,11 +499,8 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lane.needs_action && (
|
||||
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
|
||||
⚠ {lane.needs_action}
|
||||
</div>
|
||||
)}
|
||||
{/* No needs-you banner here: the strip card already flags it, and the
|
||||
console directly below is where the prompt actually waits. */}
|
||||
|
||||
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||
declare a profile. A port that drifted from `base + slot` is called
|
||||
@@ -477,31 +576,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-2 space-y-0.5 font-mono text-[11px] text-fg-secondary">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
<div className="truncate">
|
||||
⑂ {git.branch}
|
||||
<span className="ml-2 text-fg-muted">{git.head}</span>
|
||||
</div>
|
||||
<div className="truncate text-fg-muted" title={git.subject}>
|
||||
{git.subject}
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<div className="text-status-warning/80">
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
||||
<div className="truncate text-fg-muted" title={lane.cwd}>
|
||||
{lane.cwd}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="lane-child-worktrees"
|
||||
@@ -516,7 +590,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-400 hover:underline"
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
@@ -527,24 +601,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px]">
|
||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
data-testid={`lane-integration-${name}`}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
integrations[name]
|
||||
? "bg-status-success/10 text-status-success"
|
||||
: "bg-surface-2 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`integrations.${name}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||
in one grid row still line their buttons up. Repo tooling and the
|
||||
remove button live in the Workspace page header/pipeline row now —
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @file The diff renderer behind the Changes panel (issue #35): one file's
|
||||
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
|
||||
* with the changed span inside a modified line picked out.
|
||||
*
|
||||
* Three deliberate choices:
|
||||
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
|
||||
* view already ships. No new dependency for a panel that renders a few
|
||||
* hundred lines.
|
||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
* (`visibleRange`); the content's min-width comes from the longest line, so
|
||||
* the horizontal extent does not jump as rows come and go.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it. In the review list, which stacks many files, a diff
|
||||
* grows to its full height so the PANEL is the one vertical scroller — a
|
||||
* 60vh box per file nested inside it made hunk navigation scroll two boxes
|
||||
* at once and land nowhere predictable. Only a windowed diff (above
|
||||
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
|
||||
* scroller.
|
||||
*/
|
||||
function boxHeight(fill: boolean, windowed: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `el` to the top of the scroller that actually scrolls it, below
|
||||
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
|
||||
* ancestor, the page included.
|
||||
*/
|
||||
function scrollToTop(el: HTMLElement, pad: number) {
|
||||
let s = el.parentElement;
|
||||
while (
|
||||
s &&
|
||||
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
|
||||
)
|
||||
s = s.parentElement;
|
||||
if (!s) return;
|
||||
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
|
||||
}
|
||||
|
||||
/** Rows above which a view windows; at or below it renders exactly as before. */
|
||||
export const WINDOW_THRESHOLD = 500;
|
||||
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
* is clamped to the content first, so a stale offset (content that shrank, a
|
||||
* layout switch) still lands on real rows instead of an empty window.
|
||||
*/
|
||||
export function visibleRange(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
rowHeight: number,
|
||||
count: number,
|
||||
overscan: number
|
||||
) {
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
|
||||
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
||||
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
|
||||
return { start: Math.min(start, end), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowing state for one scroller of `count` items. Off (the whole range)
|
||||
* at or below the threshold.
|
||||
*/
|
||||
function useWindow(count: number) {
|
||||
const [top, setTop] = useState(0);
|
||||
const on = count > WINDOW_THRESHOLD;
|
||||
// The offset is tracked even while windowing is off, so a diff that GROWS
|
||||
// past the threshold (expanded context) windows where the reader is, not at
|
||||
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
|
||||
const lastTop = useRef(0);
|
||||
// count 0 = no scroller here (split layout): the browser drops its offset,
|
||||
// so coming back to unified must start from 0, not the pre-split row.
|
||||
if (count === 0) lastTop.current = 0;
|
||||
const [wasOn, setWasOn] = useState(on);
|
||||
if (wasOn !== on) {
|
||||
setWasOn(on);
|
||||
setTop(on ? lastTop.current : 0);
|
||||
}
|
||||
// ponytail: the viewport is taken as the window height — never smaller than
|
||||
// a box that lives inside the window, so it only over-renders a few dozen
|
||||
// rows, and it needs no ResizeObserver to follow a panel resize.
|
||||
const range = on
|
||||
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
|
||||
: { start: 0, end: count };
|
||||
const onScroll = (el: HTMLElement) => {
|
||||
lastTop.current = el.scrollTop;
|
||||
if (on) setTop(el.scrollTop);
|
||||
};
|
||||
return { on, range, onScroll };
|
||||
}
|
||||
|
||||
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
|
||||
function longest(texts: Iterable<string>) {
|
||||
let max = 0;
|
||||
for (const t of texts) {
|
||||
let n = t.length;
|
||||
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
|
||||
if (n > max) max = n;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Stands in for `rows` off-screen rows. */
|
||||
function Spacer({ rows }: { rows: number }) {
|
||||
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
|
||||
}
|
||||
|
||||
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
|
||||
function Windowed({
|
||||
count,
|
||||
range,
|
||||
minWidth,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
range: { start: number; end: number };
|
||||
minWidth: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-diff-content style={{ minWidth }}>
|
||||
<Spacer rows={range.start} />
|
||||
{children}
|
||||
<Spacer rows={count - range.end} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{highlight(text, lang).map((tok, i) => (
|
||||
<span key={i} className={tokenClass(tok.type)}>
|
||||
{tok.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The changed span of a del/add pair, as `[start, end]` offsets into each
|
||||
* side, or null when the lines share nothing worth pointing at.
|
||||
*/
|
||||
function emphasis(
|
||||
del: string,
|
||||
add: string
|
||||
): { del: [number, number]; add: [number, number] } | null {
|
||||
if (del === add) return null;
|
||||
let start = 0;
|
||||
const max = Math.min(del.length, add.length);
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
};
|
||||
}
|
||||
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const delStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "del") i += 1;
|
||||
const addStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "add") i += 1;
|
||||
const dels = addStart - delStart;
|
||||
const adds = i - addStart;
|
||||
for (let k = 0; k < Math.max(dels, adds); k += 1) {
|
||||
const left = k < dels ? lines[delStart + k]! : null;
|
||||
const right = k < adds ? lines[addStart + k]! : null;
|
||||
const e = left && right ? emphasis(left.text, right.text) : null;
|
||||
if (e) {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function Gutter({ n }: { n: number | null }) {
|
||||
return (
|
||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
tint: string;
|
||||
style?: CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
|
||||
* last changed line. A context rather than a prop because lines render from
|
||||
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (hunk: LaneDiffHunk) => ReactNode;
|
||||
hunks: LaneDiffHunk[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
|
||||
function lastChangedLine(hunk: LaneDiffHunk) {
|
||||
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
||||
if (hunk.lines[i]!.type !== "ctx") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function lastChangedRow(hunk: LaneDiffHunk) {
|
||||
const rows = analyseHunk(hunk).rows;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const r = rows[i]!;
|
||||
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW the last changed line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.hunks.indexOf(hunk);
|
||||
const total = ctx.hunks.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||||
<span
|
||||
data-testid="lane-hunk-bar"
|
||||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.prevHunk")}
|
||||
title={t("changes.prevHunk")}
|
||||
disabled={index <= 0}
|
||||
onClick={() => ctx.go(index - 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.nextHunk")}
|
||||
title={t("changes.nextHunk")}
|
||||
disabled={index >= total - 1}
|
||||
onClick={() => ctx.go(index + 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(hunk)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const ctx = useContext(HunkActions);
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
|
||||
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedLine({
|
||||
line,
|
||||
lang,
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is the hunk's last changed line: hang the review bar under it. */
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const { emph } = analyseHunk(hunk);
|
||||
const last = lastChangedLine(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{hunk.lines.map((line, i) => (
|
||||
<UnifiedLine
|
||||
key={i}
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={i === last ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
|
||||
* unified, a paired row in split. */
|
||||
type Item = { hunk: LaneDiffHunk; i: number };
|
||||
|
||||
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
|
||||
const out: Item[] = [];
|
||||
for (const hunk of hunks) {
|
||||
out.push({ hunk, i: -1 });
|
||||
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
|
||||
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
return longest(
|
||||
(function* () {
|
||||
for (const h of hunks) for (const l of h.lines) yield l.text;
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function SplitLine({
|
||||
row,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** One side of the split view: a hunk header, then that side's rows. */
|
||||
function SplitColumn({
|
||||
hunks,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
hunks: LaneDiffHunk[];
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per hunk: on the right (new) column only.
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A windowed split column: the shared range of items, one side of each. */
|
||||
function SplitSlice({
|
||||
items,
|
||||
range,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
items: Item[];
|
||||
range: { start: number; end: number };
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.slice(range.start, range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `v` held inside a scroller's own extent. */
|
||||
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
return Math.min(v, Math.max(0, scrollSize - clientSize));
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
];
|
||||
|
||||
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
|
||||
function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
for (const [pos, size, client] of AXES) {
|
||||
// `src` pinned at its own end while `dst` sits past it: `src` is the
|
||||
// narrower column echoing a clamp we just caused. Pulling `dst` back to
|
||||
// it would snap the wide column home every time it scrolls past the
|
||||
// narrow one's longest line.
|
||||
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
|
||||
const target = clampScroll(src[pos], dst[size], dst[client]);
|
||||
if (dst[pos] !== target) dst[pos] = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
// Mirroring fires the other pane's own scroll handler, which mirrors back —
|
||||
// the equality check (and `follow`'s edge rule) is what ends that, so no
|
||||
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
|
||||
// vertical drift puts a deletion beside some unrelated line. Both columns
|
||||
// window off the same offset, so they always hold the same row range.
|
||||
const sync = (from: "left" | "right") => {
|
||||
const src = from === "left" ? left.current : right.current;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
win.onScroll(src);
|
||||
};
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
|
||||
</Windowed>
|
||||
) : (
|
||||
<SplitColumn hunks={hunks} lang={lang} side={side} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
onScroll={() => sync("left")}
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-right"
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
>
|
||||
{column("right")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One file's diff, in the chosen layout. */
|
||||
export function LaneDiffView({
|
||||
file,
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each hunk's review bar. */
|
||||
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
const minWidth = useMemo(
|
||||
// two gutters + the row's px-2, plus the marker column
|
||||
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
|
||||
[hunks]
|
||||
);
|
||||
// Unified only; split windows inside its own two scrollers.
|
||||
const win = useWindow(layout === "unified" ? items.length : 0);
|
||||
const splitWindowed = useMemo(
|
||||
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
// The bar's arrows: bring hunk `index`'s header into view. A windowed
|
||||
// unified diff may not have it in the DOM, so there it is found by offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
if (!el || !hunks) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
?.querySelector<HTMLElement>("[data-sticky-top]");
|
||||
const pad = sticky?.offsetHeight ?? 0;
|
||||
if (header) {
|
||||
scrollToTop(header, pad);
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
if (!file.hunks || file.hunks.length === 0)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={i === lastChangedLine(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
|
||||
export function LaneFileView({
|
||||
path,
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
const win = useWindow(lines.length);
|
||||
// gutter + the row's px-2
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
{lines
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map((text, k) => row(text, win.range.start + k, FIXED))}
|
||||
</Windowed>
|
||||
) : (
|
||||
lines.map((text, i) => row(text, i))
|
||||
)}
|
||||
{truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneDiffView;
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
*
|
||||
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CloudUpload,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
icon,
|
||||
busy,
|
||||
disabled,
|
||||
title,
|
||||
compact,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
|
||||
compact?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
title={title}
|
||||
aria-label={compact ? label : undefined}
|
||||
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{!compact && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
lead,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
lead?: React.ReactNode;
|
||||
/** Called after any action that could have moved the working copy. */
|
||||
onChanged: () => void;
|
||||
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
||||
* a branch this bar just created. Omitted where no such flow is reachable. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
// render the moment the id changes, before anything can act on it.
|
||||
const [shownLane, setShownLane] = useState(laneId);
|
||||
// One action at a time, checked synchronously: `busy` is state, and a second
|
||||
// Enter can land before the render that disables anything.
|
||||
const inFlight = useRef(false);
|
||||
if (shownLane !== laneId) {
|
||||
setShownLane(laneId);
|
||||
setBusy(null);
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
laneNow.current = laneId;
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// A merge that stopped on conflicts still succeeded in starting; the
|
||||
// Changes panel below lists the files to resolve.
|
||||
if (res?.conflicts?.length)
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "publish") {
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
} else if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
}
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
} else if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
} finally {
|
||||
if (laneNow.current === lane) {
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
const openSwitch = async () => {
|
||||
setSwitching((v) => !v);
|
||||
setError(null);
|
||||
if (branches) return;
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{lead}
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.unborn ? (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noCommits")}
|
||||
</span>
|
||||
) : git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
>
|
||||
{(git.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{git.ahead}
|
||||
</span>
|
||||
)}
|
||||
{(git.behind ?? 0) > 0 && (
|
||||
<span className="flex items-center text-accent">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{git.behind}
|
||||
</span>
|
||||
)}
|
||||
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
|
||||
</span>
|
||||
) : (
|
||||
git.hasRemote && (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noUpstream")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
||||
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
||||
<Btn
|
||||
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A repo with no commit has nothing to push, fetch into or branch
|
||||
from — the one next step is the commit box below. */}
|
||||
{git.unborn ? (
|
||||
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
||||
) : (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
// Same `push -u <remote> HEAD` either way; a branch with no
|
||||
// upstream is being published, not updated, so it says that.
|
||||
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={
|
||||
git.hasRemote && !git.upstream
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.publish")}
|
||||
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => {
|
||||
setPublishing((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
// A branch any worktree holds — this lane's own included — is
|
||||
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
||||
title={
|
||||
picked && (heldBy.has(picked) || picked === git.branch)
|
||||
? t("gitBar.newLaneHeld", {
|
||||
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() => void run("branch-delete", { branch: picked })}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publishing && !git.hasRemote && !git.unborn && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
placeholder={t("gitBar.publishUrl")}
|
||||
aria-label={t("gitBar.publishUrl")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.publishApply")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<form
|
||||
data-testid="lane-git-login"
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { protocol, host } = login.auth;
|
||||
void run(login.action, {
|
||||
credentials: { protocol, host, username: username.trim(), password, remember },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-fg-primary">
|
||||
{t("gitBar.loginTitle", { host: login.auth.host })}
|
||||
</p>
|
||||
{login.auth.rejected && (
|
||||
<p role="alert" className="text-status-danger">
|
||||
{t("gitBar.loginRejected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
aria-label={t("gitBar.loginUser")}
|
||||
placeholder={t("gitBar.loginUser")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitBar.loginPassword")}
|
||||
placeholder={t("gitBar.loginPassword")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || !password || !!busy}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitBar.loginSubmit")}
|
||||
</button>
|
||||
<Btn
|
||||
label={t("gitBar.loginCancel")}
|
||||
onClick={() => {
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
data-testid="lane-git-bar-error"
|
||||
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneGitBar;
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts that open a per-file hover panel)
|
||||
* and the git action bar under it (`LaneGitBar`).
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
* purpose: each call is five git subprocesses server-side, and a branch name
|
||||
* does not change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched by the Changes panel rather than folded
|
||||
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders with no git header.
|
||||
*/
|
||||
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
// A lane switch drops the previous lane's facts during render, so nothing
|
||||
// (the git bar's "current branch" included) shows them until the new read.
|
||||
const [factsLane, setFactsLane] = useState(laneId);
|
||||
if (factsLane !== laneId) {
|
||||
setFactsLane(laneId);
|
||||
setFacts(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a panel that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
|
||||
* scrolls in its own `overflow` box, and an absolutely positioned panel would
|
||||
* be clipped by it. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
/** See `LaneGitBar`'s prop of the same name. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
|
||||
if (!git?.available) return null;
|
||||
const lead = (
|
||||
<>
|
||||
{/* One row with the actions: branch first and strongest, then the head
|
||||
and its subject, which is what truncates when the panel is narrow
|
||||
(the whole of it stays in the tooltip). */}
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-git-header"
|
||||
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
|
||||
>
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
lead={lead}
|
||||
/>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace's horizontal lane strip.
|
||||
* It carries only what you need to pick a lane — which lane, is it alive,
|
||||
* what stage, how far — because the full card, its controls and its
|
||||
* working-copy facts live in the detail panel below it. Keeping the tile
|
||||
* small and fixed-width is what lets many lanes stay scannable in one
|
||||
* scrolling row.
|
||||
* what stage — because the full card, its controls and its working-copy facts
|
||||
* live in the detail panel below it. Stage badges ride on the id row rather
|
||||
* than a line of their own, and progress (bar and percent) is deliberately
|
||||
* absent: the detail panel's pipeline map below shows the same thing with the
|
||||
* stage names attached. Keeping the tile small and fixed-width is what lets
|
||||
* many lanes stay scannable in one scrolling row.
|
||||
*
|
||||
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
|
||||
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
|
||||
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
|
||||
* the keyboard (and touch-free) path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
|
||||
@@ -43,46 +51,70 @@ function detectionLeads(lane: Lane): boolean {
|
||||
|
||||
export default function LaneStripCard({
|
||||
lane,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
onReorder,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Position in the lane array — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`lane-tile-${lane.id}`}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("reorderHint")}`}
|
||||
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||
{t("cardId", { id: lane.id })}
|
||||
{/* The bare id, not "Lane 23": the word cost a third of the row's
|
||||
width and said nothing, but the number itself is how the CLI
|
||||
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
@@ -100,16 +132,16 @@ export default function LaneStripCard({
|
||||
{lane.detected_stage}
|
||||
</span>
|
||||
)}
|
||||
{lane.progress > 0 && (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-fg-muted">{lane.progress}%</span>
|
||||
{lane.needs_action && (
|
||||
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lane.progress > 0 && (
|
||||
<div className="mt-1.5 h-0.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${lane.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-[13px] font-medium text-fg-primary">
|
||||
{lane.title || lane.cwd}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -106,18 +109,6 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -131,10 +122,149 @@ beforeEach(() => {
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: null,
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
profile: "native",
|
||||
});
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [nativeRouter()] });
|
||||
});
|
||||
|
||||
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved router profile, for the tests that need the radio to have a target. */
|
||||
function savedRouter(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...nativeRouter(),
|
||||
name: "9router",
|
||||
label: "9Router",
|
||||
builtin: false,
|
||||
hasKey: true,
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
model: "Highest",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
const withWorktrees = {
|
||||
branches: ["main", "feat/other", "feat/held"],
|
||||
current: "main",
|
||||
worktrees: [
|
||||
{ path: "/Users/tester/projects/repo", branch: "main" },
|
||||
{ path: "/lanes/repo__held", branch: "feat/held" },
|
||||
],
|
||||
};
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
const panel = await screen.findByTestId("add-lane-worktrees");
|
||||
expect(panel).toHaveTextContent("/lanes/repo__held");
|
||||
expect(panel).toHaveTextContent("feat/held");
|
||||
});
|
||||
|
||||
it("checks an existing branch out, sending no base", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
const select = await screen.findByLabelText("Branch to check out");
|
||||
await user.selectOptions(select, "feat/other");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: "feat/other", base: undefined })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("greys out a branch another worktree already holds", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
|
||||
const held = await screen.findByRole("option", { name: /feat\/held/ });
|
||||
expect(held).toBeDisabled();
|
||||
expect(held).toHaveTextContent("/lanes/repo__held");
|
||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("lets the server name a new branch when the field is left empty", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Auto named");
|
||||
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
|
||||
// the form cannot see, so the placeholder only says the field is optional.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Automatic — named from the title"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: undefined, base: "main" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
// The branch NAME is no longer part of this list: an empty one means "let
|
||||
// the server name it" (issue #34).
|
||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
@@ -143,6 +273,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main", "feat/other"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -174,8 +305,34 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: [],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/empty");
|
||||
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
// There is nothing to branch from, so the worktree cannot be created.
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -191,20 +348,24 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||
);
|
||||
// The modal stays open showing the setup summary until dismissed - it
|
||||
// does not close itself just because the lane was added.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
// Setup came back clean, so there is nothing to report and the dialog is
|
||||
// done. Leaving it up read as "still working" — its confirm button is dead
|
||||
// once the lane exists — which is how it looked stuck.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
@@ -223,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -242,7 +407,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("still calls onAdded even when every setup call fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -261,11 +430,16 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
@@ -273,17 +447,22 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
// A failure is the one case worth reading before the dialog goes away.
|
||||
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||
if (!dismissButton) throw new Error("Cancel button not found");
|
||||
await user.click(dismissButton);
|
||||
// And then the primary button must close, not sit there disabled: the lane
|
||||
// already exists, so "Add lane" has nothing left to do.
|
||||
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -346,8 +525,8 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
exact: true,
|
||||
})
|
||||
);
|
||||
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||
@@ -356,6 +535,28 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
// close right away like the old adopt flow did.
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the folder already has a lane instead of closing silently (#42)", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
|
||||
created: false,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
|
||||
// The existing lane is selected so the user sees it, but nothing claims a new one.
|
||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — folder browse", () => {
|
||||
@@ -398,47 +599,145 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
describe("AddLaneModal — create/update readme", () => {
|
||||
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
|
||||
// Creating a lane and writing its docs are separate decisions, and the
|
||||
// second one is worth re-running later; see LaneToolsMenu.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — AI Router", () => {
|
||||
it("applies native without asking, so a lane always has a declared endpoint", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
it("sends the picked profile once AI Router is chosen", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
|
||||
});
|
||||
|
||||
it("keeps the radio on Native when no profile is saved, and says why", async () => {
|
||||
// Choosing "AI Router" with nothing to point at would create a lane whose
|
||||
// declared endpoint does not exist.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates the lane anyway when the profile list cannot be fetched", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
|
||||
it("reports the adopted lane even when applying the router fails", async () => {
|
||||
// `ensure` already created it server-side; skipping onAdded would hide a
|
||||
// real lane from the list until some unrelated refetch found it.
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
// …and the failure is still surfaced, on a modal left open.
|
||||
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
|
||||
// Deleted in another tab while this modal sat open: the select would show
|
||||
// no matching option while state still held the dead name.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
|
||||
created: true,
|
||||
});
|
||||
const { rerender } = renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
|
||||
|
||||
// Re-open with the profile gone from the server's answer.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
// Clicking it early resolved back to Native with no feedback, which reads
|
||||
// as a dead control.
|
||||
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
|
||||
vi.mocked(api.aiRouters.list).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveList = resolve;
|
||||
}) as never
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
resolveList({ items: [nativeRouter(), savedRouter()] });
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,3 +785,41 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["development", "main"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByTestId("add-lane-branch-section");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Current branch" }));
|
||||
|
||||
// Repo mode adopts the directory as it stands — no branch section left to
|
||||
// answer, because there is no fork to configure.
|
||||
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("prefills the repo and branch it was opened with", async () => {
|
||||
renderModal({
|
||||
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
|
||||
});
|
||||
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
|
||||
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
// "adopted" is shown as context, not as a refusal.
|
||||
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
|
||||
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -191,7 +191,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
|
||||
it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -212,7 +212,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -238,7 +238,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
|
||||
it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -268,23 +268,34 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
|
||||
it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 3,
|
||||
blocked: ["unpushed-commits"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
|
||||
@@ -353,10 +364,10 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
|
||||
it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
action: "reset",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
@@ -368,13 +379,13 @@ describe("DestructiveLaneModal", () => {
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu } from "../LaneCard";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
@@ -23,14 +23,21 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
integration: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
run: {
|
||||
kill: vi.fn(),
|
||||
start: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,8 +48,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.integration).mockReset();
|
||||
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
@@ -150,19 +159,17 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the progress percentage", () => {
|
||||
// The pipeline map below this card draws the same progress as named nodes,
|
||||
// so the card no longer repeats it as a bar and a percent.
|
||||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
it("leaves the needs-you message to the strip card", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/waiting on approval/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||||
@@ -197,64 +204,11 @@ describe("LaneCard rebuilt layout", () => {
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||||
it("carries no working-copy row — that heads the Changes panel now", async () => {
|
||||
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
|
||||
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(api.lanes.git).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,40 +294,6 @@ describe("LaneCard — named locks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — integration badges", () => {
|
||||
beforeEach(() => {
|
||||
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||
// keeps the double from having to restate a shape the component never
|
||||
// touches here.
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
available: true as const,
|
||||
provisioned: true as const,
|
||||
up: false,
|
||||
slot: 1,
|
||||
profileDir: "/work/demo/.agenthub/profile",
|
||||
ports: {},
|
||||
} as unknown as LaneRuntime);
|
||||
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||
Promise.resolve({ enabled: name === "tracker" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows integration badges reflecting each toggle's state", async () => {
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||
expect(tracker.className).toContain("status-success");
|
||||
expect(devQc.className).not.toContain("status-success");
|
||||
});
|
||||
|
||||
it("hides integration badges when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneToolsMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||
@@ -433,6 +353,29 @@ describe("LaneToolsMenu", () => {
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||
});
|
||||
|
||||
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
|
||||
// Moved here from the Add-lane modal: a skill only runs inside a CLI
|
||||
// session, and writing the docs is worth re-running long after creation.
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-write-docs"));
|
||||
await waitFor(() =>
|
||||
expect(api.run.start).toHaveBeenCalledWith({
|
||||
laneId: 1,
|
||||
cwd: "/work/demo",
|
||||
initialPrompt: "/sg-all:sg-md-writing",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
|
||||
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the lane has no profile", async () => {
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
|
||||
@@ -440,3 +383,168 @@ describe("LaneToolsMenu", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "claude-sonnet-5",
|
||||
models: {
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
},
|
||||
};
|
||||
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
|
||||
|
||||
it("stays out of the card when native is the only profile", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("lane-router-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the lane's current profile selected", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a restart when a live pane still holds the old router's models", async () => {
|
||||
// Claude Code re-applies `env` to a running session but keeps the model it
|
||||
// resolved at startup, so the switch is only half-applied until the pane is
|
||||
// restarted — the server says so and the row must act on it.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: true,
|
||||
run_id: "agenthub-lane-1",
|
||||
});
|
||||
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
|
||||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||||
await userEvent.click(restart);
|
||||
|
||||
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
|
||||
// Started by lane id alone: replaying the old run's --model would name a
|
||||
// model of the router the user just left.
|
||||
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
|
||||
});
|
||||
|
||||
it("leaves the restart button out when no pane is running", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||||
// lane-detail header), so its selected value is component state that
|
||||
// outlives a lane switch unless the sync effect puts the new lane's own
|
||||
// profile back.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/b/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
// The row is one instance shared by every lane, so an apply still in flight
|
||||
// when the user moves on would paint its rollback and its error on whoever
|
||||
// is on screen by then — lane B's failure changing lane A's picker.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
let reject: (e: Error) => void = () => {};
|
||||
vi.mocked(api.lanes.routerApply).mockImplementation(
|
||||
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
|
||||
);
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
await act(async () => {
|
||||
reject(new Error("EBADJSON"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||||
// And the picker must not be left locked by a request for another lane.
|
||||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("puts the selection back when the apply fails", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* @file LaneDiffView.test.tsx
|
||||
* @description Pins the side-by-side renderer: two independently scrolling
|
||||
* columns (a long line must be reachable, not painted over its neighbour)
|
||||
* whose horizontal scroll is mirrored, so the two halves of a line stay
|
||||
* opposite each other. Also pins the clamped mirroring (a narrow column may
|
||||
* not drag the wide one back) and the row windowing of large diffs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
ROW_PX,
|
||||
WINDOW_THRESHOLD,
|
||||
analyseHunk,
|
||||
visibleRange,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,2 +1,2 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** jsdom has no layout: give a scroller the extents a browser would. */
|
||||
function layout(
|
||||
el: HTMLElement,
|
||||
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
|
||||
) {
|
||||
for (const [k, v] of Object.entries(g))
|
||||
Object.defineProperty(el, k, { value: v, configurable: true });
|
||||
}
|
||||
|
||||
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
// windowed one keeps the cap because windowing needs its own scroller.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
|
||||
const big: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
|
||||
type: "ctx" as const,
|
||||
oldLine: i + 1,
|
||||
newLine: i + 1,
|
||||
text: `line ${i}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<LaneDiffView file={big} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
|
||||
it("mirrors one column's horizontal scroll onto the other", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(320);
|
||||
|
||||
right.scrollLeft = 0;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the split columns on the same row vertically too", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollTop = 480;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollTop).toBe(480);
|
||||
|
||||
right.scrollTop = 40;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps the mirrored offset to the other column's own extent", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
right.scrollTop = 250;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(300);
|
||||
expect(left.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("does not let the narrow column's clamped echo drag the wide one back", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
fireEvent.scroll(right);
|
||||
// A browser fires the narrow column's scroll for the clamped write.
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(600);
|
||||
|
||||
// A real move of the narrow column still carries the wide one.
|
||||
left.scrollLeft = 100;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||||
});
|
||||
});
|
||||
|
||||
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
|
||||
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
|
||||
}
|
||||
|
||||
const ROWS = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
|
||||
]);
|
||||
|
||||
/** The unified row whose text contains `needle`. */
|
||||
function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
|
||||
r.textContent?.includes(needle)
|
||||
);
|
||||
if (!row) throw new Error(`no row for ${needle}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
expect(plus.className).toContain("text-status-success");
|
||||
expect(minus.textContent).toBe("-");
|
||||
expect(minus.className).toContain("text-status-danger");
|
||||
expect(ctx.className).toContain("text-fg-muted");
|
||||
expect(ctx.className).not.toContain("font-bold");
|
||||
});
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("does not emphasise a line that changed completely", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView
|
||||
file={fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
|
||||
])}
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
render(
|
||||
<LaneDiffView
|
||||
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
|
||||
layout="split"
|
||||
/>
|
||||
);
|
||||
const rows = (id: string) => [
|
||||
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
const [right] = rows("lane-diff-split-right");
|
||||
const [left] = rows("lane-diff-split-left");
|
||||
expect(right!.className).toContain("bg-status-success/20");
|
||||
expect(left!.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyseHunk", () => {
|
||||
const hunk = {
|
||||
header: "@@ -1,4 +1,4 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
||||
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
|
||||
],
|
||||
};
|
||||
|
||||
it("pairs each del/add with its emphasis, keyed by line index", () => {
|
||||
const a = analyseHunk(hunk);
|
||||
expect(a.rows).toHaveLength(3);
|
||||
expect(a.emph.get(1)).toEqual([6, 7]);
|
||||
expect(a.emph.get(2)).toEqual([6, 7]);
|
||||
expect(a.emph.get(3)).toEqual([6, 7]);
|
||||
expect(a.emph.get(4)).toEqual([6, 7]);
|
||||
expect(a.emph.has(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("computes once per hunk, so both split columns and re-renders share it", () => {
|
||||
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleRange", () => {
|
||||
it("covers the viewport plus overscan on both sides", () => {
|
||||
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
|
||||
});
|
||||
|
||||
it("clamps a scrollTop past the content to the last full viewport", () => {
|
||||
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
|
||||
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
|
||||
it("never runs past a short list", () => {
|
||||
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
|
||||
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function bigFile(n: number): LaneChangeFile {
|
||||
const lines = Array.from({ length: n }, (_, i) =>
|
||||
i % 2
|
||||
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
|
||||
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
|
||||
);
|
||||
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
|
||||
return fileWith(lines);
|
||||
}
|
||||
|
||||
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
|
||||
|
||||
describe("LaneDiffView — windowing", () => {
|
||||
it("renders every row below the threshold, with no spacers", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
|
||||
);
|
||||
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
|
||||
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const rows = rowsIn(container);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThan(300);
|
||||
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
|
||||
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
|
||||
// header + 5000 lines, every item one fixed row tall
|
||||
const rendered = container.querySelectorAll("[data-diff-item]").length;
|
||||
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
|
||||
});
|
||||
|
||||
it("moves the window with the scroll", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
expect(container.textContent).not.toContain("line 3000;");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("line 3000;");
|
||||
expect(container.textContent).not.toContain("line 10;");
|
||||
});
|
||||
|
||||
it("starts windowing where the reader is when a diff grows past the threshold", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 350 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
// Expanding context replaces the file in place, same mounted view.
|
||||
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 350;");
|
||||
});
|
||||
|
||||
it("starts from the top again after a round trip through split", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
// In split the outer box is no scroller; the browser drops its offset.
|
||||
screen.getByTestId("lane-diff-view").scrollTop = 0;
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 5;");
|
||||
});
|
||||
|
||||
it("holds the horizontal extent at the longest line, rendered or not", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
|
||||
// the 300-w line 7 is off screen now, and still sets the width
|
||||
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
|
||||
});
|
||||
|
||||
it("windows both split columns over the same row range", () => {
|
||||
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
right.scrollTop = 2000 * ROW_PX;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(2000 * ROW_PX);
|
||||
const l = rowsIn(left);
|
||||
const r = rowsIn(right);
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
});
|
||||
|
||||
it("windows a whole-file read too", () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
|
||||
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
|
||||
expect(rowsIn(container).length).toBeLessThan(300);
|
||||
const view = screen.getByTestId("lane-file-view");
|
||||
view.scrollTop = 4000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("row 4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
const hunk = (n: number) => ({
|
||||
header: `@@ -${n},1 +${n},1 @@`,
|
||||
context: "",
|
||||
oldStart: n,
|
||||
newStart: n,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
|
||||
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
|
||||
],
|
||||
});
|
||||
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
|
||||
|
||||
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
|
||||
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
|
||||
function renderInScroller() {
|
||||
const view = render(
|
||||
<div data-testid="scroller" style={{ overflowY: "auto" }}>
|
||||
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
|
||||
</div>
|
||||
);
|
||||
const scroller = screen.getByTestId("scroller");
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 100 });
|
||||
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
|
||||
const k = Number(el.dataset.hunkIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
}
|
||||
|
||||
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
|
||||
const scroller = renderInScroller();
|
||||
const next = screen.getAllByRole("button", { name: "Next change" });
|
||||
const prev = screen.getAllByRole("button", { name: "Previous change" });
|
||||
|
||||
fireEvent.click(next[0]!); // bar 1 → hunk 2
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
fireEvent.click(next[1]!); // bar 2 → hunk 3
|
||||
expect(scroller.scrollTop).toBe(200);
|
||||
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("counts the hunks and disables the arrows at either end", () => {
|
||||
renderInScroller();
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
|
||||
render(<LaneDiffView file={three} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
return {
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
unborn: false,
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: true,
|
||||
upstream: "origin/feat/thing",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "fetch",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
vi.mocked(api.lanes.gitBranches)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneGitBar", () => {
|
||||
it("shows both sides of the upstream comparison", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
const tracking = screen.getByTestId("lane-git-tracking");
|
||||
expect(tracking).toHaveTextContent("2");
|
||||
expect(tracking).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("disables the network actions when the repo has no remote", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still branches.
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({
|
||||
branch: "main",
|
||||
head: null,
|
||||
unborn: true,
|
||||
subject: "",
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
})}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
||||
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a repo with no remote to a typed URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
||||
url: "git@host:owner/repo.git",
|
||||
})
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={onNewLane}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "feat/sibling",
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "New lane here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing", "feat/free"],
|
||||
current: "feat/thing",
|
||||
worktrees: [
|
||||
{ path: "/repo/lane3", branch: "feat/thing" },
|
||||
{ path: "/repo/dev", branch: "development" },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const newLane = () => screen.getByRole("button", { name: "New lane here" });
|
||||
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await user.selectOptions(select, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
||||
});
|
||||
|
||||
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
||||
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
||||
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolveOld = r;
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New lane here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
});
|
||||
|
||||
it("claims no sync state when the upstream comparison could not be counted", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("in sync")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
||||
"No remote to push to or pull from"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git login (issue #45)", () => {
|
||||
const authErr = (rejected = false) =>
|
||||
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
||||
code: "EGITAUTH",
|
||||
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
||||
});
|
||||
|
||||
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
||||
credentials: {
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "s3cret",
|
||||
remember: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockRejectedValueOnce(authErr())
|
||||
.mockRejectedValueOnce(authErr(true));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
||||
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
||||
);
|
||||
});
|
||||
|
||||
it("disables pull and switch while a merge is in progress", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file LaneGitHeader.test.tsx
|
||||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||||
* an unreadable repo renders nothing rather than an error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
/** The panel's own wiring: one facts read feeding the header. */
|
||||
function Harness({ laneId }: { laneId: number }) {
|
||||
const [git, refresh] = useLaneGitFacts(laneId);
|
||||
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
|
||||
}
|
||||
|
||||
describe("LaneGitHeader", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
const { rerender } = render(<Harness laneId={1} />);
|
||||
await screen.findByText(/feat\/rename-metric/);
|
||||
|
||||
// The next lane's read never lands: nothing of lane 1's git may linger.
|
||||
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
|
||||
rerender(<Harness laneId={2} />);
|
||||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<Harness laneId={1} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders nothing and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<Harness laneId={2} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
|
||||
expect(screen.queryByTestId("lane-git-header")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<Harness laneId={3} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the panel unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<Harness laneId={1} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file Rendering tests for the Workspace lane tile, focused on the amber
|
||||
* "detected" badge: it may only appear when the inferred stage is genuinely
|
||||
* AHEAD of the declared one, including when the declaration used a node alias
|
||||
* (which is most of what the ship-feature skill declares).
|
||||
* @file Rendering tests for the Workspace lane tile: the amber "detected"
|
||||
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
|
||||
* declared one, including when the declaration used a node alias — which is
|
||||
* most of what the ship-feature skill declares), and the reorder handles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import LaneStripCard from "../LaneStripCard";
|
||||
import type { Lane, LaneNode } from "../../../lib/types";
|
||||
|
||||
@@ -63,8 +63,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
@@ -77,8 +79,10 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "implementing" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
|
||||
@@ -88,10 +92,104 @@ describe("LaneStripCard detected badge", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ stage: "tests", detected_stage: "review" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneStripCard reorder", () => {
|
||||
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
|
||||
function dataTransfer(initial = "") {
|
||||
let payload = initial;
|
||||
return {
|
||||
setData: (_type: string, v: string) => {
|
||||
payload = v;
|
||||
},
|
||||
getData: () => payload,
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
};
|
||||
}
|
||||
|
||||
it("reports the dragged tile's index and the drop target's", () => {
|
||||
const onReorder = vi.fn();
|
||||
const dt = dataTransfer();
|
||||
render(
|
||||
<>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 1 })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 2 })}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
|
||||
expect(onReorder).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it("ignores a drop carrying no tile index", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
|
||||
const onReorder = vi.fn();
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({})}
|
||||
index={2}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-1");
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 1);
|
||||
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
|
||||
expect(onReorder).toHaveBeenCalledWith(2, 3);
|
||||
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
|
||||
fireEvent.keyDown(tile, { key: "ArrowLeft" });
|
||||
expect(onReorder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the id without the 'Lane' prefix", () => {
|
||||
render(
|
||||
<LaneStripCard
|
||||
lane={lane({ id: 7, title: "checkout flow" })}
|
||||
index={0}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
onReorder={() => {}}
|
||||
/>
|
||||
);
|
||||
const tile = screen.getByTestId("lane-tile-7");
|
||||
expect(tile).toHaveTextContent("#7");
|
||||
expect(tile.textContent).not.toMatch(/Lane/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,20 @@
|
||||
* swaps the whole pane over to the new lane — its cwd, its tool, its history,
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* Each pane is one tmux session on its lane — split panes all show the same
|
||||
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* (with a notice explaining what happened), since that session is still
|
||||
* reachable and the user did not ask to kill it. `killRun` is the other half:
|
||||
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
|
||||
* DOES want that previous session gone so a fresh start/resume can proceed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -38,6 +48,20 @@ import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
* prefix, the same rule the server binds hook sessions with. External
|
||||
* sessions are all claude — Codex emits no hooks. */
|
||||
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
|
||||
let best: Lane | null = null;
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== "claude") continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
@@ -53,6 +77,19 @@ export interface LaneConsolePaneProps {
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** Which split pane this is. Every pane shows the SAME lane, each in its own
|
||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
||||
slot?: number;
|
||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
||||
* one terminal is never attached twice side by side. */
|
||||
hiddenRunIds?: string[];
|
||||
/** Reports the run this pane shows (null on the setup form). */
|
||||
onHandleChange?: (runId: string | null) => void;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
@@ -68,6 +105,9 @@ export function LaneConsolePane({
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -75,7 +115,7 @@ export function LaneConsolePane({
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||
@@ -88,12 +128,37 @@ export function LaneConsolePane({
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
const laneTool = currentLane?.tool ?? "claude";
|
||||
// The page polls runs and sessions for EVERY lane; this pane's Active runs
|
||||
// list is this lane's only. No lane selected keeps the full lists.
|
||||
const laneRuns = useMemo(
|
||||
() =>
|
||||
laneId === null || !activeRuns
|
||||
? activeRuns
|
||||
: {
|
||||
...activeRuns,
|
||||
items: activeRuns.items.filter(
|
||||
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
|
||||
),
|
||||
},
|
||||
[activeRuns, laneId, hiddenRunIds]
|
||||
);
|
||||
const laneSessions = useMemo(
|
||||
() =>
|
||||
laneId === null
|
||||
? externalSessions
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
}, [handle, onHasActiveRunChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onHandleChange?.(handle?.id ?? null);
|
||||
}, [handle, onHandleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
@@ -129,43 +194,19 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
const { activeRuns: runs } = latest.current;
|
||||
setPrompt("");
|
||||
lastPrefill.current = "";
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
||||
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
|
||||
autoAttachedForLane.current = null;
|
||||
refreshList();
|
||||
}, [laneId, refreshList]);
|
||||
}, [laneId, slot, refreshList]);
|
||||
|
||||
// Pre-fill the prompt with the slash command the lane's pipeline template
|
||||
// names as its driver, so picking the 16-node pipeline starts the 16-step
|
||||
// skill and the 8-node one the 8-step skill. Only overwrites a prompt the
|
||||
// user hasn't touched (still exactly the previous prefill), so switching
|
||||
// templates never eats a typed requirement. Must be declared AFTER the
|
||||
// lane-switch effect: on a lane change that one clears both first.
|
||||
const lastPrefill = useRef("");
|
||||
// Claude gets a slash command it can execute (`pipeline_skill`), which itself
|
||||
// declares stage at every step. Codex has no slash commands — the same
|
||||
// string would read as literal prose — so it gets the equivalent priming a
|
||||
// different way: a plain-text checklist of this lane's own pipeline node ids,
|
||||
// read directly off `pipeline_nodes` so it always matches the template in
|
||||
// use. Without SOME opening nudge a codex lane's declared stage depends
|
||||
// entirely on AGENTS.md being read on its own, which is easy to miss.
|
||||
const promptPrefill =
|
||||
laneTool === "codex"
|
||||
? currentLane?.pipeline_nodes?.length
|
||||
? `Declare progress as you go: run \`agenthub stage <stage> --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n`
|
||||
: ""
|
||||
: currentLane?.pipeline_skill || "";
|
||||
useEffect(() => {
|
||||
const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill;
|
||||
setPrompt((p) => (p === lastPrefill.current ? next : p));
|
||||
lastPrefill.current = next;
|
||||
// `laneId` is a dep too: switching between two lanes on the SAME template
|
||||
// leaves `promptPrefill` unchanged, and the switch effect just cleared the
|
||||
// prompt — without it the new lane would open with an empty box.
|
||||
}, [promptPrefill, laneTool, laneId]);
|
||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||
// run starts, which is an extra prompt nobody asked for. The prompt is the
|
||||
// user's to type; the pipeline is declared by `agenthub stage` instead.
|
||||
|
||||
// The switch effect above only sees whatever `activeRuns` the page already
|
||||
// had loaded at that instant. Remounting this page (navigating away and
|
||||
@@ -177,12 +218,13 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
if (handle || laneId === null) return;
|
||||
if (autoAttachedForLane.current === laneId) return;
|
||||
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
||||
const own = paneRunId(laneId, slot);
|
||||
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
|
||||
if (running) {
|
||||
autoAttachedForLane.current = laneId;
|
||||
setHandle(running);
|
||||
}
|
||||
}, [activeRuns, laneId, handle]);
|
||||
}, [activeRuns, laneId, slot, handle]);
|
||||
|
||||
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||
@@ -261,6 +303,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: args.initialPrompt || "",
|
||||
model: args.model || undefined,
|
||||
permissionMode: args.permissionMode,
|
||||
@@ -271,29 +314,31 @@ export function LaneConsolePane({
|
||||
resumeLast: args.resumeLast,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
return;
|
||||
}
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
// already has a live run"), never the literal strings "409" or
|
||||
// "ERUNLIVE" — matching against `.message` here is dead code that
|
||||
// always falls through to the raw error below.
|
||||
// This pane's own session is the live one — land on it.
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error(t("errors.noRunIdReturned"));
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
const fetched = await api.run.get(startedId);
|
||||
setHandle(fetched);
|
||||
refreshList();
|
||||
} catch {
|
||||
try {
|
||||
await attachToRun(laneStartResult.lane.run_id);
|
||||
await attachToRun(startedId);
|
||||
refreshList();
|
||||
} catch (fallbackErr: unknown) {
|
||||
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||
@@ -307,7 +352,18 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||
[
|
||||
busy,
|
||||
t,
|
||||
lanes,
|
||||
laneId,
|
||||
laneTool,
|
||||
slot,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
attachToRun,
|
||||
refreshList,
|
||||
]
|
||||
);
|
||||
|
||||
const onResumeFromHistory = useCallback(
|
||||
@@ -332,19 +388,38 @@ export function LaneConsolePane({
|
||||
onLaneCreated(ensureResult.lane);
|
||||
}
|
||||
|
||||
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
// The lane's own pane is still busy with a PREVIOUS live session —
|
||||
// that session is still active and reachable, so land on it
|
||||
// instead of failing outright. Killing it (Active runs → Kill) is
|
||||
// what actually frees the lane for this resume. Checked by `.code`
|
||||
// — the server's `.message` is prose, never the literal string
|
||||
// "ERUNLIVE" or "409".
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
}
|
||||
|
||||
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
fetched = await api.run.get(startedId);
|
||||
onLaneIdChange(targetLaneId);
|
||||
} else {
|
||||
fetched = await api.run.start({
|
||||
@@ -368,7 +443,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -385,6 +460,28 @@ export function LaneConsolePane({
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Frees a lane blocked by a live run nobody wants anymore (see Active
|
||||
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
|
||||
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
|
||||
const killRun = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy("kill");
|
||||
setError(null);
|
||||
try {
|
||||
await api.run.kill(id);
|
||||
if (handle?.id === id) newRun();
|
||||
refreshList();
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[handle, newRun, refreshList, t]
|
||||
);
|
||||
|
||||
if (laneId === null && showLaneSelector) {
|
||||
return (
|
||||
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
@@ -426,13 +523,17 @@ export function LaneConsolePane({
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||
description of the panel, not a message, so it does not earn the
|
||||
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||
where the width would wrap it again. */}
|
||||
<header className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-3.5 h-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<h1 className="text-base font-semibold text-fg-primary">
|
||||
{t("title", { tool: toolLabel })}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
@@ -447,16 +548,19 @@ export function LaneConsolePane({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
||||
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||
{t("subtitle", { tool: toolLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
activeRuns={laneRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
externalSessions={laneSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
</header>
|
||||
@@ -510,6 +614,13 @@ export function LaneConsolePane({
|
||||
<TerminalView
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// go straight back to the setup form rather than leaving a dead
|
||||
// terminal the user has to click "New run" out of.
|
||||
onExit={() => {
|
||||
newRun();
|
||||
refreshList();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={newRun}
|
||||
|
||||
@@ -10,18 +10,23 @@
|
||||
* - `RunsModal` — the merged list itself: live in-memory handles from
|
||||
* `activeRuns` deduped against persistent `runHistory`, newest first, with
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
* Resume / View / Kill actions.
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
|
||||
* runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
|
||||
* discarding a file in the Changes panel) is what actually frees a lane whose
|
||||
* pane is still busy with a previous session — `LaneConsolePane` needs it
|
||||
* because starting or resuming into a lane already holding a live run answers
|
||||
* 409 ERUNLIVE rather than overwriting a prompt in flight.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
@@ -52,7 +58,7 @@ function StatusPill({
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-status-success/10 text-status-success border-status-success/30",
|
||||
gone: "bg-surface-3 text-fg-secondary border-border",
|
||||
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
|
||||
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
||||
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
|
||||
killed: "bg-surface-3 text-fg-secondary border-border",
|
||||
abandoned: "bg-surface-3 text-fg-secondary border-border",
|
||||
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onKill,
|
||||
onRefresh,
|
||||
}: {
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
|
||||
* spawned rows only — an external session has no tmux session here to
|
||||
* kill. */
|
||||
onKill: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
onKill={onKill}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
@@ -273,6 +285,7 @@ export function RunsModal({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
runHistory,
|
||||
onClose,
|
||||
onRefresh,
|
||||
@@ -282,6 +295,7 @@ export function RunsModal({
|
||||
onAttach: (id: string) => void;
|
||||
onResume: (item: DashboardRunHistoryItem) => void;
|
||||
onView: (item: DashboardRunHistoryItem) => void;
|
||||
onKill: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -446,6 +460,7 @@ export function RunsModal({
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onView(h);
|
||||
}}
|
||||
onKill={() => onKill(r.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -512,23 +527,32 @@ function UnifiedRunRowView({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
}: {
|
||||
row: UnifiedRunRow;
|
||||
isCurrent: boolean;
|
||||
onAttach: () => void;
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
onKill: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
// Two clicks before the tmux session actually dies — same convention as
|
||||
// discarding a file's changes in the Changes panel, for the one action
|
||||
// here that cannot be undone.
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
const startedDate = new Date(row.startedAt);
|
||||
const startedLabel = isNaN(startedDate.getTime())
|
||||
? "-"
|
||||
: startedDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no
|
||||
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
|
||||
const startedLabel =
|
||||
!row.startedAt || isNaN(startedDate.getTime())
|
||||
? "-"
|
||||
: startedDate.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// Without mode distinction, offer resume for any finished run with a session.
|
||||
// An external session is live but has no attachable tmux session, so Resume
|
||||
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||
@@ -550,7 +574,7 @@ function UnifiedRunRowView({
|
||||
)}
|
||||
{row.external && (
|
||||
<span
|
||||
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("runs.externalHint")}
|
||||
>
|
||||
{t("runs.externalBadge")}
|
||||
@@ -590,6 +614,32 @@ function UnifiedRunRowView({
|
||||
{t("runs.viewLabel", "View")}
|
||||
</button>
|
||||
)}
|
||||
{row.isLive && !row.external && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirmKill) {
|
||||
setConfirmKill(true);
|
||||
return;
|
||||
}
|
||||
setConfirmKill(false);
|
||||
onKill();
|
||||
}}
|
||||
onBlur={() => setConfirmKill(false)}
|
||||
title={
|
||||
confirmKill
|
||||
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
|
||||
: t("runs.killLabel", "Kill")
|
||||
}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
|
||||
confirmKill
|
||||
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
||||
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{row.promptPreview && (
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ShieldAlert,
|
||||
X,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
History as HistoryIcon,
|
||||
Search,
|
||||
@@ -52,45 +53,305 @@ import type {
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
source: "project" | "user" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
"compact",
|
||||
"config",
|
||||
"model",
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
"status",
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"output-style",
|
||||
"exit",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
||||
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
||||
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
||||
if (!q) return 1;
|
||||
const n = name.toLowerCase();
|
||||
if (n === q) return 1000;
|
||||
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
||||
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
||||
const idx = n.indexOf(q);
|
||||
if (idx >= 0) return 400 - Math.min(idx, 100);
|
||||
let i = 0;
|
||||
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
||||
if (i === q.length) return 200;
|
||||
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
kind: "slash" | "file";
|
||||
query: string;
|
||||
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
||||
start: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
||||
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
||||
function detectTrigger(value: string, cursor: number): Trigger | null {
|
||||
let start = cursor;
|
||||
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
||||
const tok = value.slice(start, cursor);
|
||||
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
||||
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
slash,
|
||||
fileCwd,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
||||
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
||||
if (commandsCwd !== fileCwd) {
|
||||
setCommandsCwd(fileCwd);
|
||||
setCommands(null);
|
||||
}
|
||||
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
||||
|
||||
// Commands and skills are read the first time `/` is typed for this cwd —
|
||||
// two directory walks nobody should pay for on every render of the form.
|
||||
useEffect(() => {
|
||||
if (kind !== "slash" || commands) return;
|
||||
let live = true;
|
||||
const toCmd = (c: CcMdItem): SlashCommand => ({
|
||||
name: c.name,
|
||||
source: c.scope,
|
||||
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
||||
});
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [kind, commands, fileCwd]);
|
||||
|
||||
// Files: debounced, and only the newest query may land.
|
||||
const query = trigger?.query ?? "";
|
||||
useEffect(() => {
|
||||
if (kind !== "file") return;
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(fileCwd, query)
|
||||
.then((r) => live && setFiles(r.items))
|
||||
.catch(() => live && setFiles([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [kind, query, fileCwd]);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (kind !== "slash" || !commands) return [];
|
||||
const q = query.toLowerCase();
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
||||
a.cmd.name.localeCompare(b.cmd.name)
|
||||
)
|
||||
.map((x) => x.cmd);
|
||||
}, [kind, commands, query]);
|
||||
const count = kind === "file" ? files.length : slashItems.length;
|
||||
const current = Math.min(active, Math.max(0, count - 1));
|
||||
|
||||
const pick = (token: string) => {
|
||||
if (!trigger) return;
|
||||
const before = value.slice(0, trigger.start);
|
||||
const after = value.slice(trigger.cursor);
|
||||
const sep = after.startsWith(" ") ? "" : " ";
|
||||
onChange(before + token + sep + after);
|
||||
setTrigger(null);
|
||||
setActive(0);
|
||||
const pos = before.length + token.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
const pickAt = (i: number) => {
|
||||
if (kind === "file") {
|
||||
const f = files[i];
|
||||
if (f) pick(`@${f}`);
|
||||
} else {
|
||||
const c = slashItems[i];
|
||||
if (c) pick(`/${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
pickAt(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const retrack = (ta: HTMLTextAreaElement) => {
|
||||
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
||||
};
|
||||
|
||||
const rowClass = (i: number) =>
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
{kind && (
|
||||
<div
|
||||
role="listbox"
|
||||
data-testid="prompt-autocomplete"
|
||||
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
||||
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
||||
</div>
|
||||
) : kind === "slash" ? (
|
||||
slashItems.map((c, i) => (
|
||||
<button
|
||||
key={`${c.source}:${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
// Keep focus in the textarea, or its blur closes the list first.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={rowClass(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
||||
>
|
||||
{c.source}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +380,6 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** The selected lane's CLI. Decides which advanced fields render. */
|
||||
@@ -147,9 +407,84 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||
settings left, source right. They used to be two rows with a divider
|
||||
between them, which spent a band of height on four labelled selects
|
||||
that are each one line tall. The resume picker below is the only part
|
||||
that still gets a row of its own, and only while resume is chosen. */}
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "auto", label: t("fields.permissionAuto") },
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
@@ -166,8 +501,11 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||
<div className="min-w-0">
|
||||
{props.tool === "claude" ? (
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
@@ -192,8 +530,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -206,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
slash={props.tool !== "codex"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
@@ -214,78 +552,6 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced fields */}
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("fields.cwd")}>
|
||||
{isResume && props.resumeSession ? (
|
||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("fields.cwdHint")}>
|
||||
<CwdAutocomplete
|
||||
value={props.cwd}
|
||||
onChange={props.onCwdChange}
|
||||
suggestions={props.cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
value={props.sandbox}
|
||||
onChange={props.onSandboxChange}
|
||||
options={[
|
||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.approval")}>
|
||||
<Select<CodexApproval>
|
||||
value={props.approval}
|
||||
onChange={props.onApprovalChange}
|
||||
options={[
|
||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label={t("fields.permissionMode")}>
|
||||
<Select<PermissionMode>
|
||||
value={props.permissionMode}
|
||||
onChange={props.onPermissionModeChange}
|
||||
options={[
|
||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||
{ value: "default", label: t("fields.permissionDefault") },
|
||||
{ value: "plan", label: t("fields.permissionPlan") },
|
||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
@@ -355,7 +621,10 @@ function handleStart(props: RunSetupProps, session?: Session) {
|
||||
effort: props.effort || undefined,
|
||||
resumeSessionId: codex ? undefined : resume?.id || undefined,
|
||||
resumeLast: codex && !resume ? true : undefined,
|
||||
initialPrompt: props.prompt || undefined,
|
||||
// A resume continues a session that already has its own history — sending
|
||||
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||
// asked for, so a resume carries no prompt at all.
|
||||
initialPrompt: resume ? undefined : props.prompt || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,7 +709,10 @@ export function CwdAutocomplete({
|
||||
// Group suggestions by kind preserving fixed order — home first, matching
|
||||
// the neutral default the page pre-fills (issue #202).
|
||||
const groups = useMemo(() => {
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
||||
// "repo" (source repos lanes were cut from) sits above the recents: on the
|
||||
// Add-lane form it is the group the field is actually asking for, and on
|
||||
// the Run form it is a short, deliberate list rather than history.
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
|
||||
return order
|
||||
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
@@ -516,6 +788,8 @@ export function CwdAutocomplete({
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
) : g.kind === "home" ? (
|
||||
<Home className="w-3 h-3" />
|
||||
) : g.kind === "repo" ? (
|
||||
<GitBranch className="w-3 h-3" />
|
||||
) : (
|
||||
<HistoryIcon className="w-3 h-3" />
|
||||
)}
|
||||
@@ -674,7 +948,7 @@ function SessionPicker({
|
||||
s.status === "active"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: s.status === "completed"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: s.status === "error"
|
||||
? "bg-status-danger/10 text-status-danger border-status-danger/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border"
|
||||
|
||||
@@ -9,21 +9,86 @@
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
/** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit).
|
||||
* There is nothing left to attach to, so the owner swaps this terminal back
|
||||
* out for its run setup form instead of leaving a dead pane on screen. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||
// every render, and as an effect dep it would tear the terminal down and
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
|
||||
// assumes a dark background. Read through a ref at construction so a theme
|
||||
// change never re-runs the attach effect; the effect below recolours live.
|
||||
const darkTheme = useTheme().themeFor.dark;
|
||||
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -35,12 +100,21 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too.
|
||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||
// it on a PTY, where termios already does that translation for the streams
|
||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||
// drawing at the column it was on; with this on, that draw landed at
|
||||
// column 0 instead, stamping the first characters of the line into the
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
@@ -63,6 +137,10 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
term.focus();
|
||||
|
||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||
@@ -116,7 +194,16 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
let disposed = false;
|
||||
|
||||
const connect = () => {
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||
// The attach itself carries this terminal's size. tmux sizes the window
|
||||
// to the client that attached most recently, so spawning the attach at a
|
||||
// default 80×24 and correcting it from the first `resize` message a beat
|
||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||
// columns first — and everything painted at that width stays in the
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each 1.5 s reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
// would hand onmessage a Blob that the string checks below never match,
|
||||
@@ -143,6 +230,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
@@ -195,8 +283,54 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||
useEffect(() => {
|
||||
if (termRef.current) termRef.current.options.theme = palette;
|
||||
}, [palette]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
// out of claude), which the real TerminalView reports through `onExit`.
|
||||
TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId}>
|
||||
<button data-testid="fake-pane-exit" onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -37,6 +42,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
|
||||
const LANE: Lane = {
|
||||
id: 1,
|
||||
router_profile: null,
|
||||
title: "demo",
|
||||
cwd: "/workspace/a",
|
||||
branch: null,
|
||||
@@ -129,6 +135,35 @@ describe("LaneConsolePane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "run-1" },
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-1",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await screen.findByTestId("terminal-view");
|
||||
|
||||
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
@@ -194,40 +229,11 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => {
|
||||
const lane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
|
||||
const props = { ...baseProps(), lanes: [lane] };
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature "));
|
||||
|
||||
// Untouched prefill follows the template switch.
|
||||
const laneShip = { ...lane, pipeline_skill: "/agenthub:ship-feature-lane" };
|
||||
rerender(<LaneConsolePane {...props} lanes={[laneShip]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane "));
|
||||
|
||||
// A typed requirement is never eaten by a later template switch.
|
||||
fireEvent.change(prompt, { target: { value: "/agenthub:ship-feature-lane add a button" } });
|
||||
rerender(<LaneConsolePane {...props} lanes={[lane]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane add a button"));
|
||||
});
|
||||
|
||||
it("never pre-fills a claude slash command into a codex pane", async () => {
|
||||
// pipeline_skill is a Claude Code slash command. Codex would take it as
|
||||
// literal prose and run nothing, which is how a codex lane sits for half an
|
||||
// hour with an untouched pipeline map. No pipeline_nodes here either, so
|
||||
// there's nothing to build a stage checklist from — the box stays empty.
|
||||
const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/agenthub:ship-feature-lane" };
|
||||
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toBe(""));
|
||||
});
|
||||
|
||||
it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => {
|
||||
// Codex has no slash commands, so it can't run pipeline_skill as-is — it
|
||||
// gets the same priming Claude gets (an opening nudge to declare stage)
|
||||
// as plain text instead, naming this lane's actual node ids.
|
||||
const lane = {
|
||||
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
|
||||
// Picking a pipeline used to write its driving slash command (claude) or a
|
||||
// stage checklist (codex) into the box — an extra prompt sent on start.
|
||||
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
|
||||
const codexLane = {
|
||||
...LANE,
|
||||
tool: "codex" as const,
|
||||
pipeline_skill: "/agenthub:ship-feature-lane",
|
||||
@@ -242,11 +248,81 @@ describe("LaneConsolePane", () => {
|
||||
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
|
||||
],
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
|
||||
const props = { ...baseProps(), lanes: [claudeLane] };
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(prompt.value).toContain("agenthub stage <stage> --tool codex"));
|
||||
expect(prompt.value).toContain("implement -> tests");
|
||||
expect(prompt.value).not.toContain("/agenthub:ship-feature-lane");
|
||||
await waitFor(() => expect(prompt.value).toBe(""));
|
||||
|
||||
// A typed requirement still survives a template switch.
|
||||
fireEvent.change(prompt, { target: { value: "add a button" } });
|
||||
rerender(
|
||||
<LaneConsolePane
|
||||
{...props}
|
||||
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
||||
|
||||
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
|
||||
await waitFor(() => expect(prompt.value).toBe("add a button"));
|
||||
});
|
||||
|
||||
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
|
||||
const HISTORY_ITEM = {
|
||||
id: "run-old",
|
||||
session_id: "sess-old",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
resume_session_id: null,
|
||||
prompt_preview: "earlier prompt",
|
||||
status: "abandoned" as const,
|
||||
exit_code: null,
|
||||
started_at: new Date(1000).toISOString(),
|
||||
ended_at: new Date(2000).toISOString(),
|
||||
isLive: false,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
// Matches the real shape `request()` throws: a plain, human-readable
|
||||
// `.message` plus a `.code` a caller actually branches on.
|
||||
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
|
||||
);
|
||||
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lanes: [{ ...LANE, run_id: "run-live" }],
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-live",
|
||||
laneId: 1,
|
||||
status: "running",
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane {...baseProps()} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
|
||||
const resumeButtons = await screen.findAllByRole("button", {
|
||||
name: "Resume existing session",
|
||||
});
|
||||
fireEvent.click(resumeButtons[0]!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
|
||||
);
|
||||
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||
@@ -255,4 +331,126 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
||||
const run = (id: string, laneId: number, promptPreview: string) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running",
|
||||
cwd: "/workspace/x",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
});
|
||||
const session = (id: string, cwd: string, name: string) =>
|
||||
({
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
model: null,
|
||||
source: "local",
|
||||
started_at: "2026-09-24T01:00:00Z",
|
||||
}) as never;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
lanes={[
|
||||
LANE,
|
||||
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
||||
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
||||
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
||||
]}
|
||||
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
||||
externalSessions={[
|
||||
session("s1", "/workspace/a/src", "my session"),
|
||||
session("s2", "/workspace/b", "b session"),
|
||||
session("s3", "/workspace/a/nested/x", "nested session"),
|
||||
session("s4", "/workspace/ab", "sibling prefix"),
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.getByText("my session")).toBeTruthy();
|
||||
expect(screen.queryByText("other lane's")).toBeNull();
|
||||
expect(screen.queryByText("b session")).toBeNull();
|
||||
expect(screen.queryByText("nested session")).toBeNull();
|
||||
expect(screen.queryByText("sibling prefix")).toBeNull();
|
||||
});
|
||||
|
||||
describe("split panes on one lane", () => {
|
||||
const run = (id: string, promptPreview = id) => ({
|
||||
id,
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
tool: "claude" as const,
|
||||
});
|
||||
|
||||
it("attaches a pane only to its own slot's session", async () => {
|
||||
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-2"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("starts into its own slot and shows the session the server opened", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "agenthub-lane-1" },
|
||||
run_id: "agenthub-lane-1-1",
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
|
||||
render(<LaneConsolePane {...baseProps()} slot={1} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ slot: 1 })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-1"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves runs other panes show out of Active runs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
slot={1}
|
||||
activeRuns={{
|
||||
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
|
||||
}}
|
||||
hiddenRunIds={["agenthub-lane-1"]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
|
||||
expect(screen.getByText("free")).toBeTruthy();
|
||||
expect(screen.queryByText("shown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
@@ -127,6 +128,7 @@ function renderModal(
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
@@ -242,6 +244,14 @@ describe("ActiveRunsSwitcher", () => {
|
||||
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a dash, not the epoch, for a row with no start time", () => {
|
||||
// A run whose persisted row is missing arrives with startedAt 0. Rendering
|
||||
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
|
||||
renderModal([row("no-start", { startedAt: 0 })]);
|
||||
expect(screen.getByText("-")).toBeTruthy();
|
||||
expect(screen.queryByText(/Jan 1,/)).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
@@ -266,6 +276,19 @@ describe("RunsModal", () => {
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
|
||||
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
||||
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
|
||||
fireEvent.click(killButton);
|
||||
expect(spies.onKill).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
|
||||
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
|
||||
unmount();
|
||||
|
||||
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
|
||||
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
scope: "project",
|
||||
name: "ship-it",
|
||||
frontmatter: { description: "Ship the change" },
|
||||
preview: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
@@ -121,8 +136,10 @@ describe("RunSetup — selections report through callbacks", () => {
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
const { spies } = renderSetup();
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet 5");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
|
||||
// what keeps the list from going stale on every model release.
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
@@ -234,6 +251,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no prompt when resuming, even with text in the box", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||
|
||||
// The resumed session is the input; anything left in the box would be an
|
||||
// extra turn the user never asked for.
|
||||
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||
@@ -277,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
@@ -341,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`.
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -16,6 +17,12 @@ const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -25,9 +32,13 @@ const termState = {
|
||||
rows: 28,
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
|
||||
...(ctorArgs.push(opts), {}),
|
||||
options: termOptions,
|
||||
get modes() {
|
||||
return termState.modes;
|
||||
},
|
||||
@@ -54,8 +65,14 @@ vi.mock("@xterm/xterm", () => ({
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
vi.mock("../../../lib/terminalTheme", () => ({
|
||||
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
@@ -100,16 +117,49 @@ describe("TerminalView", () => {
|
||||
keyHandlerMock.mockClear();
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
focusMock.mockClear();
|
||||
disposeMock.mockClear();
|
||||
ctorArgs.length = 0;
|
||||
delete termOptions.theme;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("opens a WS connection to the run's ws-pty path", () => {
|
||||
it("opens a WS connection to the run's ws-pty path, carrying its own size", () => {
|
||||
// The size rides on the attach URL, not on a `resize` message sent after
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
|
||||
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
|
||||
localStorage.setItem("agenthub.theme.dark", "apple-dark");
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
|
||||
|
||||
act(() => {
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
});
|
||||
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(disposeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes incoming WS data to the terminal", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
@@ -184,6 +234,24 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(focusMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells its owner when the pane exited, so the setup form can come back", () => {
|
||||
// A double Ctrl+C out of `claude` ends the tmux session; the dead pane used
|
||||
// to sit there until the user found the "New run" button.
|
||||
const onExit = vi.fn();
|
||||
render(
|
||||
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
|
||||
);
|
||||
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
@@ -252,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance: the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
"intellij-light": "IntelliJ Light",
|
||||
canvas: "Canvas",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
|
||||
data-mode={mode}
|
||||
data-theme={name === "default" ? undefined : name}
|
||||
>
|
||||
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-accent" />
|
||||
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-tight">
|
||||
<span className="text-syntax-keyword">const</span>{" "}
|
||||
<span className="text-syntax-function">x</span>{" "}
|
||||
<span className="text-syntax-operator">=</span>{" "}
|
||||
<span className="text-syntax-string">"ok"</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
|
||||
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
return (
|
||||
<section id="appearance" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Palette className="w-4 h-4 text-fg-muted" />
|
||||
{t("appearance.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
|
||||
{MODES.map(({ mode: m, Icon }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={mode === m}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(`appearance.modes.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as const).map((group) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-medium text-fg-secondary mb-2">
|
||||
{t(`appearance.${group}Themes`)}
|
||||
{theme === group && (
|
||||
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? t("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
|
||||
className={`rounded-lg border p-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
.getAllByRole("button", { pressed: true })
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
|
||||
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /Darcula/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /Canvas/ }));
|
||||
expect(root.dataset.theme).toBe("darcula");
|
||||
await user.click(screen.getByRole("button", { name: /^Light$/ }));
|
||||
expect(root.classList.contains("dark")).toBe(false);
|
||||
expect(root.dataset.theme).toBe("canvas");
|
||||
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("the System mode button is pressed after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("button", { name: /System/ }));
|
||||
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
});
|
||||
|
||||
it("each swatch scopes its own mode and theme", () => {
|
||||
const { container } = render(<AppearanceSettings />);
|
||||
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x2", innerW)
|
||||
.attr("y1", (d) => yScale(d))
|
||||
.attr("y2", (d) => yScale(d))
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Y axis (sessions)
|
||||
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
)
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#9ca3af")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", bx + bw / 2)
|
||||
.attr("y", by - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#a5b4fc")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("y", innerH - 1)
|
||||
.attr("width", bw)
|
||||
.attr("height", 1)
|
||||
.attr("fill", "#2a2a3d");
|
||||
.attr("fill", "rgb(var(--border))");
|
||||
}
|
||||
|
||||
// Transparent, full-height hover target on top of the bar.
|
||||
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
label={t("compaction.sessionsAffected")}
|
||||
value={affected.toLocaleString()}
|
||||
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
||||
accent="text-violet-300"
|
||||
accent="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.avgPerSession")}
|
||||
value={avgPerSession.toFixed(1)}
|
||||
accent="text-blue-400"
|
||||
accent="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.peakSession")}
|
||||
@@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
||||
{tip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
||||
top: tip.y - 10,
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke="rgb(var(--status-success))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
|
||||
style={{ color: isHovered ? color : "#9ca3af" }}
|
||||
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
|
||||
>
|
||||
{d.count}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file LanePipelinePanel.tsx
|
||||
* @description How often each node of this dashboard's OWN pipelines actually
|
||||
* gets passed, aggregated over every lane and every archived feature by
|
||||
* gets passed, aggregated over every recorded run by
|
||||
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
|
||||
* with evidence / passed without / failed, plus the raw counts, so a stage that
|
||||
* everyone claims but nobody proves is visible at a glance. A lane picker
|
||||
* narrows the same aggregation to one lane and its archived features.
|
||||
* with evidence / passed without / failed / reached but only inferred, plus the
|
||||
* raw counts, so a stage that everyone claims but nobody proves is visible at a
|
||||
* glance. A lane picker narrows the same aggregation to one lane.
|
||||
* Read-only — the live per-lane view and every lane action stay on the
|
||||
* Workspace page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -101,7 +101,9 @@ export function LanePipelinePanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
|
||||
// Detected-only reaches share the scale: a node the hooks saw fifty times and
|
||||
// nobody ever declared should read as busy, not as empty.
|
||||
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -127,6 +129,7 @@ export function LanePipelinePanel() {
|
||||
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
|
||||
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
|
||||
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
|
||||
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,11 +181,23 @@ export function LanePipelinePanel() {
|
||||
style={{ width: `${pct(n.failed, maxEntered)}%` }}
|
||||
title={t("lanes.legendFailed")}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-fg-muted/60"
|
||||
style={{ width: `${pct(n.detected, maxEntered)}%` }}
|
||||
title={t("lanes.legendDetected")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{n.entered === 0 ? (
|
||||
t("lanes.never")
|
||||
// Reached but never declared still beats "never reached" — it
|
||||
// is the difference between "you skip this stage" and "you do
|
||||
// it without saying so".
|
||||
n.detected > 0 ? (
|
||||
t("lanes.detectedOnly", { count: n.detected })
|
||||
) : (
|
||||
t("lanes.never")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
|
||||
|
||||
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("x", (_, i) => layerXPositions[i] ?? 0)
|
||||
.attr("y", 22)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "10px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("y1", 32)
|
||||
.attr("x2", sepX)
|
||||
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
|
||||
.attr("stroke", "#1f1f30")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4 4");
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.append("path")
|
||||
.attr("d", path)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
|
||||
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
|
||||
.attr("stroke-width", isZero ? 1 : stroke)
|
||||
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
||||
.attr("stroke-linecap", "round");
|
||||
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
||||
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.style.color = "rgb(var(--fg-muted))";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.style.cssText =
|
||||
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
|
||||
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
|
||||
desc.textContent = meta.description;
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? state.x - 12 : state.x + 12,
|
||||
top: state.y - 10,
|
||||
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
|
||||
<div className="mt-1 pt-1 border-t border-border">
|
||||
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
|
||||
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
|
||||
</span>
|
||||
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
|
||||
|
||||
// Grid lines
|
||||
const gridColor = "#2a2a3d";
|
||||
const gridColor = "rgb(var(--border))";
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-x")
|
||||
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.tickFormat((d) => fmtXTick(d as number))
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// X axis label
|
||||
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 44)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.duration"));
|
||||
|
||||
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
g.append("g")
|
||||
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// Y axis label
|
||||
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -40)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.agentCount"));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
case "working":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "error":
|
||||
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
|
||||
case "active":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "waiting":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
default:
|
||||
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
|
||||
}
|
||||
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors"
|
||||
className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
|
||||
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
|
||||
>
|
||||
{/* Depth connector line */}
|
||||
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
@@ -252,10 +252,10 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
{events.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
|
||||
>
|
||||
{/* Tool pill */}
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{ev.tool_name ?? ev.event_type}
|
||||
</span>
|
||||
|
||||
@@ -286,8 +286,8 @@ interface EventSequenceProps {
|
||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||
tool_use: "text-blue-500",
|
||||
tool_result: "text-status-success",
|
||||
agent_start: "text-indigo-400",
|
||||
agent_stop: "text-violet-400",
|
||||
agent_start: "text-indigo-700 dark:text-indigo-400",
|
||||
agent_stop: "text-violet-700 dark:text-violet-400",
|
||||
compaction: "text-status-warning",
|
||||
error: "text-status-danger",
|
||||
};
|
||||
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
|
||||
{recent.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group"
|
||||
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
|
||||
>
|
||||
{/* Event type badge */}
|
||||
<span
|
||||
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(s.id)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0"
|
||||
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
|
||||
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||
</button>
|
||||
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.close")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
|
||||
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
|
||||
// as its own color in both the filter row and the result label chips.
|
||||
const PHASE_PALETTE = [
|
||||
"bg-violet-500/15 text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-300 border-sky-500/40",
|
||||
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
|
||||
"bg-status-warning/15 text-status-warning border-status-warning/40",
|
||||
"bg-status-success/15 text-status-success border-status-success/40",
|
||||
"bg-rose-500/15 text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
|
||||
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
|
||||
];
|
||||
|
||||
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
|
||||
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
|
||||
if (!controlled && loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
|
||||
<span className="animate-pulse">{t("runs.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
||||
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(run.session_id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
title={t("runs.openSession")}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
|
||||
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
|
||||
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
|
||||
{ts?.loading && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{t("runs.loadingFull")}
|
||||
</span>
|
||||
|
||||
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
|
||||
|
||||
function successRateColor(rate: number): string {
|
||||
if (rate > 90) return "text-status-success";
|
||||
if (rate > 70) return "text-yellow-400";
|
||||
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
|
||||
return "text-status-danger";
|
||||
}
|
||||
|
||||
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
|
||||
<span className="text-base font-semibold text-fg-primary tabular-nums">
|
||||
{valueDisplay}
|
||||
</span>
|
||||
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgAgentDepth")}
|
||||
value={stats.avgDepth.toFixed(1)}
|
||||
icon={GitFork}
|
||||
accentClass="text-indigo-400"
|
||||
accentClass="text-indigo-700 dark:text-indigo-400"
|
||||
calculationKey="stats.tooltip.calc.depth"
|
||||
interp={interpAvgDepth(stats.avgDepth)}
|
||||
metricPhraseKey="stats.tooltip.phrase.depth"
|
||||
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.mostCommonFlow")}
|
||||
value={topFlowLabel}
|
||||
icon={ArrowRightLeft}
|
||||
accentClass="text-violet-400"
|
||||
accentClass="text-violet-700 dark:text-violet-400"
|
||||
calculationKey="stats.tooltip.calc.topFlow"
|
||||
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
|
||||
metricPhraseKey="stats.tooltip.phrase.topFlow"
|
||||
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgCompactions")}
|
||||
value={stats.avgCompactions.toFixed(1)}
|
||||
icon={Layers}
|
||||
accentClass="text-cyan-400"
|
||||
accentClass="text-cyan-700 dark:text-cyan-400"
|
||||
calculationKey="stats.tooltip.calc.compactions"
|
||||
interp={interpAvgCompactions(stats.avgCompactions)}
|
||||
metricPhraseKey="stats.tooltip.phrase.compactions"
|
||||
|
||||
@@ -29,6 +29,7 @@ const node = (id: string, over: Record<string, number | string> = {}) => ({
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
detected: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -114,6 +115,24 @@ describe("LanePipelinePanel", () => {
|
||||
|
||||
const ship = await screen.findByTestId("pipeline-stat-ship");
|
||||
expect(ship).not.toHaveTextContent("%");
|
||||
expect(ship).toHaveTextContent(/never reached/i);
|
||||
});
|
||||
|
||||
it("separates a node the hooks saw from one that never happened", async () => {
|
||||
// `gate` was reached in two runs but never declared. Reporting that as
|
||||
// "never reached" is what made the panel look broken to someone who works
|
||||
// without running the pipeline commands.
|
||||
statsMock.mockResolvedValue({
|
||||
...STATS,
|
||||
pipelines: STATS.pipelines.map((p, i) =>
|
||||
i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p
|
||||
),
|
||||
});
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
const gate = await screen.findByTestId("pipeline-stat-gate");
|
||||
expect(gate).not.toHaveTextContent(/never reached/i);
|
||||
expect(gate).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("switches templates through the picker", async () => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @file Unit tests for useNotifications — which websocket events raise a
|
||||
* browser notification, and which preference gates each one. Covers issue #29:
|
||||
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
|
||||
* box once notifications are enabled, since those are the two moments the user
|
||||
* is the blocker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useNotifications } from "../useNotifications";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
function enable(patch: Partial<NotifPrefs> = {}) {
|
||||
localStorage.setItem(
|
||||
NOTIF_KEY,
|
||||
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
|
||||
);
|
||||
}
|
||||
|
||||
function emit(event_type: string, summary: string) {
|
||||
eventBus.publish({
|
||||
type: "new_event",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { event_type, summary } as DashboardEvent,
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("useNotifications", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// notify() posts to /api/push/send first and only falls back to a local
|
||||
// Notification when that throws, so the fetch spy is what we assert on.
|
||||
fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: async () => ({ ok: true, native: true, pushed: 1 }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal(
|
||||
"Notification",
|
||||
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function sentTitles() {
|
||||
return fetchMock.mock.calls
|
||||
.filter((c) => c[0] === "/api/push/send")
|
||||
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
|
||||
}
|
||||
|
||||
it("notifies on a confirmation request and on the end of a turn by default", async () => {
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "Claude needs your permission to use Bash");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
|
||||
|
||||
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
|
||||
expect(sentTitles()[1]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("respects each gate independently", async () => {
|
||||
enable({ onAwaitingInput: false });
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
||||
expect(sentTitles()[0]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("shows a local notification when the push landed nowhere", async () => {
|
||||
// What a browser with no push subscription gets back: HTTP 200, zero
|
||||
// deliveries. Before this, the 200 alone counted as success and the user
|
||||
// saw nothing at all.
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, native: false, pushed: 0, failed: 0 }),
|
||||
});
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(Notification).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(Notification).mock.calls[0]?.[0]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("does not double up when the push was delivered", async () => {
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(Notification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays silent while notifications are disabled", async () => {
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(sentTitles()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file useTheme.test.ts
|
||||
* @description Tests for useTheme — the dark/light mode hook.
|
||||
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTheme } from "../useTheme";
|
||||
|
||||
@@ -12,9 +12,12 @@ describe("useTheme", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-mode");
|
||||
});
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("defaults to dark when localStorage is empty", () => {
|
||||
@@ -46,4 +49,165 @@ describe("useTheme", () => {
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("system mode follows prefers-color-scheme, live", () => {
|
||||
let dark = true;
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
get matches() {
|
||||
return dark;
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setMode("system"));
|
||||
expect(result.current.mode).toBe("system");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
act(() => {
|
||||
dark = false;
|
||||
listeners.forEach((fn) => fn());
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("system mode without matchMedia falls back to dark", () => {
|
||||
vi.stubGlobal("matchMedia", undefined);
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("each mode remembers its own named theme", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("light", "canvas"));
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "default"));
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("an unknown stored theme id falls back to default", () => {
|
||||
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("a light theme id is never applied in dark mode", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "canvas"));
|
||||
expect(result.current.themeFor.dark).toBe("default");
|
||||
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
|
||||
});
|
||||
|
||||
it("two hook instances stay in sync", () => {
|
||||
const a = renderHook(() => useTheme());
|
||||
const b = renderHook(() => useTheme());
|
||||
act(() => a.result.current.setTheme("light"));
|
||||
act(() => a.result.current.setThemeFor("light", "apple-light"));
|
||||
expect(b.result.current.theme).toBe("light");
|
||||
expect(b.result.current.themeFor.light).toBe("apple-light");
|
||||
});
|
||||
|
||||
it("another tab's storage write updates a mounted hook", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
act(() => {
|
||||
localStorage.setItem("theme", "light");
|
||||
localStorage.setItem("agenthub.theme.light", "canvas");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
|
||||
});
|
||||
expect(result.current.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("canvas");
|
||||
expect(document.documentElement.dataset.mode).toBe("light");
|
||||
});
|
||||
|
||||
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
|
||||
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
|
||||
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
|
||||
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
|
||||
expect(result.current.theme).toBe("dark");
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("setThemeFor with an unknown id keeps the previous pick", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
act(() => result.current.setThemeFor("dark", "no-such-theme"));
|
||||
act(() => result.current.setThemeFor("light", ""));
|
||||
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
|
||||
expect(document.documentElement.dataset.theme).toBe("darcula");
|
||||
});
|
||||
|
||||
it("an unknown stored mode falls back to dark", () => {
|
||||
localStorage.setItem("theme", "blue");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("dark");
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.mode).toBe("dark");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, "light"],
|
||||
[false, "dark"],
|
||||
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: osDark,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
localStorage.setItem("theme", "system");
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current.mode).toBe("system");
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(result.current.mode).toBe(expected);
|
||||
expect(result.current.theme).toBe(expected);
|
||||
expect(localStorage.getItem("theme")).toBe(expected);
|
||||
});
|
||||
|
||||
it("unmount removes the matchMedia listener", () => {
|
||||
const listeners = new Set<() => void>();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
|
||||
}));
|
||||
const { unmount } = renderHook(() => useTheme());
|
||||
expect(listeners.size).toBeGreaterThan(0);
|
||||
unmount();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* - `../i18n`
|
||||
* - `../lib/eventBus`
|
||||
* - `../lib/push`
|
||||
* - `../lib/notify`
|
||||
* - `../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
@@ -48,90 +49,14 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { subscribeToPush } from "../lib/push";
|
||||
import type { WSMessage, Session, Agent, DashboardEvent } from "../lib/types";
|
||||
|
||||
const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
/** User's browser-notification preferences, persisted to `localStorage` under
|
||||
* {@link NOTIF_KEY} (written by the Settings page's notifications panel). */
|
||||
interface NotifPrefs {
|
||||
/** Master switch; when false, no notification types fire regardless of the
|
||||
* per-event flags below. */
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
/** Reads {@link NotifPrefs} from `localStorage`, merging over safe defaults so
|
||||
* a partial/older saved object (or none at all) still yields a valid result.
|
||||
* `enabled` defaults to false (opt-in) even in the "no saved value" branch,
|
||||
* while individual event toggles default to a sensible starting mix. */
|
||||
function loadPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw)
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
...JSON.parse(raw),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a browser notification, preferring a server-relayed push (so it can
|
||||
* arrive even if this tab isn't the active one, or the browser is backgrounded)
|
||||
* and falling back to a local service-worker/`Notification` call if the
|
||||
* server is unreachable. No-ops when the user hasn't granted permission.
|
||||
* @param title Notification title.
|
||||
* @param body Notification body text.
|
||||
*/
|
||||
async function notify(title: string, body: string) {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
try {
|
||||
await fetch("/api/push/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body }),
|
||||
});
|
||||
} catch {
|
||||
// Server unreachable - fall back to local notification
|
||||
try {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, { body, icon: "/favicon.ico", silent: false });
|
||||
} else {
|
||||
new Notification(title, { body, icon: "/favicon.ico" });
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
import { notify } from "../lib/notify";
|
||||
import { loadNotifPrefs } from "../lib/notifPrefs";
|
||||
import type { WSMessage, Session, Agent, DashboardEvent, Lane } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Wires the dashboard's {@link eventBus} up to browser notifications, per the
|
||||
@@ -143,14 +68,41 @@ async function notify(title: string, body: string) {
|
||||
* already been granted, so push delivery survives a page reload.
|
||||
*/
|
||||
export function useNotifications() {
|
||||
// A lane's `session_id` is its current/last-run session — matching on it
|
||||
// is how a click lands on the lane that actually raised the notification,
|
||||
// instead of just focusing whichever browser tab was already open.
|
||||
const lanesRef = useRef<Lane[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = loadPrefs();
|
||||
const prefs = loadNotifPrefs();
|
||||
if (prefs.enabled && "Notification" in window && Notification.permission === "granted") {
|
||||
subscribeToPush().catch(() => {});
|
||||
}
|
||||
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => (lanesRef.current = r.lanes))
|
||||
.catch(() => {});
|
||||
|
||||
function laneUrlForSession(sessionId: string): string | undefined {
|
||||
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
|
||||
return lane ? `/workspace?lane=${lane.id}` : undefined;
|
||||
}
|
||||
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
const prefs = loadPrefs();
|
||||
if (msg.type === "lane_update") {
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
if (payload.lane) {
|
||||
const i = lanesRef.current.findIndex((l) => l.id === payload.lane!.id);
|
||||
if (i === -1) lanesRef.current = [...lanesRef.current, payload.lane];
|
||||
else lanesRef.current = lanesRef.current.map((l, idx) => (idx === i ? payload.lane! : l));
|
||||
} else if (payload.removed !== undefined) {
|
||||
lanesRef.current = lanesRef.current.filter((l) => l.id !== payload.removed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = loadNotifPrefs();
|
||||
if (!prefs.enabled) return;
|
||||
|
||||
switch (msg.type) {
|
||||
@@ -159,7 +111,8 @@ export function useNotifications() {
|
||||
const s = msg.data as Session;
|
||||
notify(
|
||||
i18n.t("errors:notifications.newSession"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -168,7 +121,8 @@ export function useNotifications() {
|
||||
if (s.status === "error" && prefs.onSessionError) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.sessionError"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -177,26 +131,39 @@ export function useNotifications() {
|
||||
if (!prefs.onSubagentSpawn) return;
|
||||
const a = msg.data as Agent;
|
||||
if (a.type === "subagent") {
|
||||
notify(i18n.t("errors:notifications.subagentSpawned"), a.name);
|
||||
notify(
|
||||
i18n.t("errors:notifications.subagentSpawned"),
|
||||
a.name,
|
||||
laneUrlForSession(a.session_id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_event": {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
if (ev.event_type === "Stop" && prefs.onSessionComplete) {
|
||||
const url = laneUrlForSession(ev.session_id);
|
||||
// The two states where Claude is BLOCKED on the user get their own
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
// `onSessionComplete` / nothing at all, so the one thing the user
|
||||
// actually waits for was either off by default or unswitchable.
|
||||
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.awaitingInput"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody"),
|
||||
url
|
||||
);
|
||||
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.finishedResponding"),
|
||||
ev.summary || i18n.t("errors:notifications.readyForInput")
|
||||
ev.summary || i18n.t("errors:notifications.readyForInput"),
|
||||
url
|
||||
);
|
||||
} else if (ev.event_type === "SessionEnd" && prefs.onSessionComplete) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.sessionCompleted"),
|
||||
ev.summary || i18n.t("errors:notifications.sessionClosed")
|
||||
);
|
||||
} else if (ev.event_type === "Notification") {
|
||||
notify(
|
||||
i18n.t("errors:notifications.defaultTitle"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody")
|
||||
ev.summary || i18n.t("errors:notifications.sessionClosed"),
|
||||
url
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user