Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a | |||
| 4aae61297f | |||
| 837da6e5de | |||
| 7ca1134c3d | |||
| daf5b7be92 | |||
| f0500dc592 |
@@ -131,6 +131,20 @@
|
|||||||
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
|
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
|
||||||
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
|
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
|
||||||
|
|
||||||
|
# ── Privacy and push ingest (Settings → Data) ────────────────────────────────
|
||||||
|
# All three are normally saved from Settings into the settings file; set here,
|
||||||
|
# they are pinned and the Settings page shows them read-only.
|
||||||
|
# Redact secrets (API keys, tokens, bearer headers, private keys, password=…)
|
||||||
|
# from stored event payloads and summaries: 1 = on (default: off).
|
||||||
|
# AGENTHUB_REDACT_SECRETS=1
|
||||||
|
# Folders whose hooks are dropped whole (and skipped by the history import and
|
||||||
|
# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder
|
||||||
|
# and everything under it.
|
||||||
|
# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private
|
||||||
|
# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push
|
||||||
|
# ingest off. Settings → Data → Push ingest makes the token and stores only this.
|
||||||
|
# AGENTHUB_PUSH_TOKEN_SHA256=
|
||||||
|
|
||||||
# ── Lanes (durable parallel-work units and managed git worktrees) ────────────
|
# ── Lanes (durable parallel-work units and managed git worktrees) ────────────
|
||||||
# Directory dashboard-managed worktrees are provisioned under. It is also the
|
# Directory dashboard-managed worktrees are provisioned under. It is also the
|
||||||
# boundary safety check 2 resolves every destructive lane path against: nothing
|
# boundary safety check 2 resolves every destructive lane path against: nothing
|
||||||
|
|||||||
@@ -78,3 +78,6 @@ __pycache__/
|
|||||||
|
|
||||||
# JetBrains IDE config — machine-specific, never shared
|
# JetBrains IDE config — machine-specific, never shared
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md)
|
||||||
|
monitoring/prometheus/dashboard-token
|
||||||
|
|||||||
+71
-8
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
#
|
#
|
||||||
# Pre-commit: auto-format staged files, then run the full test suite.
|
# Pre-commit: auto-format staged files, then run the tests the commit needs —
|
||||||
# The commit is ABORTED unless every test passes.
|
# both full suites for a big or risky commit, only the related tests for a
|
||||||
|
# small one, none for docs alone (see step 2). The commit is ABORTED unless
|
||||||
|
# every test it runs passes.
|
||||||
#
|
#
|
||||||
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
|
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
|
||||||
# script on `npm install`). No husky/lint-staged runtime required.
|
# script on `npm install`). No husky/lint-staged runtime required.
|
||||||
@@ -44,13 +46,13 @@ fi
|
|||||||
# is deterministic: it fails both runs and still blocks the commit. This keeps
|
# is deterministic: it fails both runs and still blocks the commit. This keeps
|
||||||
# the gate strict without making commits a dice roll on machine load.
|
# the gate strict without making commits a dice roll on machine load.
|
||||||
run_suite() {
|
run_suite() {
|
||||||
# $1 = human label, $2 = npm script
|
# $1 = human label, $2 = shell command
|
||||||
echo "🧪 Running $1 tests..."
|
echo "🧪 Running $1 tests..."
|
||||||
if npm run "$2"; then
|
if sh -c "$2"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
|
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
|
||||||
if npm run "$2"; then
|
if sh -c "$2"; then
|
||||||
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
|
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
|
||||||
echo " If this keeps happening, the flaky test above deserves a real fix."
|
echo " If this keeps happening, the flaky test above deserves a real fix."
|
||||||
return 0
|
return 0
|
||||||
@@ -59,7 +61,68 @@ run_suite() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
run_suite backend test:server
|
# The frontend suite needs NODE_ENV=test: a shell exporting
|
||||||
run_suite frontend test:client
|
# NODE_ENV=production gets React's production build, where act() throws.
|
||||||
|
CLIENT_FULL="NODE_ENV=test npm run test:client"
|
||||||
|
|
||||||
echo "✅ Formatting applied and all tests passed — proceeding with commit."
|
# How much to run. A small commit runs only the tests that touch what it
|
||||||
|
# stages; a big or risky one runs both full suites, as does
|
||||||
|
# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing.
|
||||||
|
# client/ files → `vitest related`: vitest's own import graph.
|
||||||
|
# anything else → every server/__tests__ file that names the file's module
|
||||||
|
# (a string match: wide on purpose, `db` reaches most).
|
||||||
|
# ponytail: name matching misses a test that reaches a module only through
|
||||||
|
# another one; the full run before a release still catches that.
|
||||||
|
SMALL_MAX=10
|
||||||
|
CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)"
|
||||||
|
COUNT=$(printf '%s\n' "$CODE" | grep -c . || true)
|
||||||
|
FULL=0
|
||||||
|
[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1
|
||||||
|
[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1
|
||||||
|
# A dependency, build/test config, this hook or a deleted file can break
|
||||||
|
# anything, whatever imports it.
|
||||||
|
if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then
|
||||||
|
FULL=1
|
||||||
|
fi
|
||||||
|
if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then
|
||||||
|
FULL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COUNT" -eq 0 ]; then
|
||||||
|
echo "📝 Docs only — no tests to run."
|
||||||
|
elif [ "$FULL" = "1" ]; then
|
||||||
|
echo "🧪 Full run ($COUNT code files staged)."
|
||||||
|
run_suite backend "npm run test:server"
|
||||||
|
run_suite frontend "$CLIENT_FULL"
|
||||||
|
else
|
||||||
|
echo "🧪 Small commit ($COUNT code files) — running the related tests only."
|
||||||
|
OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)"
|
||||||
|
CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)"
|
||||||
|
|
||||||
|
if [ -n "$OTHER" ]; then
|
||||||
|
RELATED="$(
|
||||||
|
printf '%s\n' "$OTHER" | while IFS= read -r f; do
|
||||||
|
case "$f" in
|
||||||
|
server/__tests__/*.test.js) echo "$f" ;;
|
||||||
|
*)
|
||||||
|
name="$(basename "$f")"
|
||||||
|
name="${name%.*}"
|
||||||
|
grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done | sort -u | tr '\n' ' '
|
||||||
|
)"
|
||||||
|
if [ -n "$RELATED" ]; then
|
||||||
|
run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED"
|
||||||
|
else
|
||||||
|
echo "⏭️ No server test names a staged file — backend skipped."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CLIENT" ]; then
|
||||||
|
LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')"
|
||||||
|
run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Formatting applied and the tests passed — proceeding with commit."
|
||||||
|
|||||||
+41
-11
@@ -325,14 +325,15 @@ graph TD
|
|||||||
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
||||||
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
||||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||||
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
|
||||||
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
|
||||||
|
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
|
||||||
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
|
||||||
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
|
||||||
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
||||||
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
|
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
|
||||||
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
|
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
|
||||||
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there |
|
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a caller's `maxAgeMs` reusing a result that young (`/api/workflows` 30 s, `/api/analytics`'s events scans 10 s — args are the key, so a member's scoped result is never shared), a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there. The server's WAL checkpoint (`walCheckpoint`, every `DASHBOARD_WAL_CHECKPOINT_MS`, default 5 s) also runs here, on a second writable connection of the worker: the main connection sets `wal_autocheckpoint = 0`, so no hook's `COMMIT` pays for a checkpoint; a failed checkpoint turns the automatic one back on |
|
||||||
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
|
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
|
||||||
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
|
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
|
||||||
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
|
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
|
||||||
@@ -351,8 +352,8 @@ graph TD
|
|||||||
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
|
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
|
||||||
| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle |
|
| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle |
|
||||||
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
|
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
|
||||||
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
|
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) and `?tool=` (an AI tool id; `parseScope` folds both into one predicate over `sessions`) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
|
||||||
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
| `server/lib/scoped-stats.js` | Scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` or `?tool=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
|
||||||
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
|
||||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||||
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
|
||||||
@@ -367,6 +368,7 @@ graph TD
|
|||||||
| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. |
|
| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. |
|
||||||
| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. |
|
| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. |
|
||||||
| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. |
|
| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. |
|
||||||
|
| `lib/cursor-usage.js` | Cursor sessions (no tokens: Cursor's transcripts carry none) from `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`, every `DASHBOARD_CURSOR_POLL_MS`. See **Cursor Sessions**. |
|
||||||
| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. |
|
| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. |
|
||||||
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
|
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
|
||||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||||
@@ -485,7 +487,7 @@ graph TD
|
|||||||
### Splash & loading UX
|
### Splash & loading UX
|
||||||
|
|
||||||
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
||||||
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#924224` — Claude's terracotta three steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||||
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
||||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||||
@@ -850,7 +852,7 @@ erDiagram
|
|||||||
alert_rules {
|
alert_rules {
|
||||||
TEXT id PK "UUID"
|
TEXT id PK "UUID"
|
||||||
TEXT name "User-facing rule name"
|
TEXT name "User-facing rule name"
|
||||||
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold"
|
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold|cost_threshold|budget (no CHECK; validated in lib/alerts.js)"
|
||||||
TEXT config "Type-specific JSON config"
|
TEXT config "Type-specific JSON config"
|
||||||
INTEGER enabled "1|0"
|
INTEGER enabled "1|0"
|
||||||
INTEGER cooldown_seconds "Per-scope dedup window, default 300"
|
INTEGER cooldown_seconds "Per-scope dedup window, default 300"
|
||||||
@@ -909,10 +911,10 @@ erDiagram
|
|||||||
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
||||||
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
|
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
|
||||||
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
|
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
|
||||||
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type_tool` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
||||||
| `idx_events_type` | events | `event_type` | Filter events by type |
|
| `idx_events_type` | events | `event_type` | Filter events by type |
|
||||||
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
||||||
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
|
| `idx_events_session_type_tool` | events | `session_id, event_type, tool_name, created_at` | Per-session event-type filters, and a covering read for session stats (per-tool and per-type counts), the hook's TurnDuration/APIError dedupe and the import's newest-per-type resume point (1.2 s → 20 ms on a 29K-event session). Replaces `idx_events_session_type` (dropped at startup), so a hook writes no more index entries than before |
|
||||||
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
||||||
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
||||||
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
||||||
@@ -964,6 +966,11 @@ All messages are JSON with this envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Frames a hook produces are sent only after its transaction committed and its
|
||||||
|
pane binding (`bindRunSession`) ran — in order, in the same tick — so no frame
|
||||||
|
describes a rolled-back row, and a server-mode member's first frames of a new
|
||||||
|
session reach them. `new_event` carries the event's row `id` (additive).
|
||||||
|
|
||||||
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
||||||
sync poller and the `/api/remote-sources` routes) carries
|
sync poller and the `/api/remote-sources` routes) carries
|
||||||
`{ id, status, error?, last_sync_at? }`, where `status` is one of
|
`{ id, status, error?, last_sync_at? }`, where `status` is one of
|
||||||
@@ -1731,6 +1738,29 @@ is ever inferred.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Cursor Sessions
|
||||||
|
|
||||||
|
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
|
||||||
|
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
|
||||||
|
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
|
||||||
|
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
||||||
|
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
||||||
|
`tool = 'cursor'`: id = file name, name = the first `<user_query>` line (60
|
||||||
|
chars), start = the file's birth time (mtime where there is none), `active`
|
||||||
|
while it was written in the last two minutes, else `completed` at its last
|
||||||
|
write. The cwd is the project slug walked back onto the filesystem (every
|
||||||
|
non-alphanumeric character is a `-` in the slug, so only a path that still
|
||||||
|
exists resolves); otherwise it stays NULL. `cursor_transcripts` holds the mtime
|
||||||
|
read, so a session the user deleted stays deleted until Cursor writes to it again.
|
||||||
|
|
||||||
|
**No tokens, no model, no cost.** A transcript line is `{role, message:{content}}`
|
||||||
|
or `{type: "turn_ended"}` — no usage, no model id, no timestamps — so a Cursor
|
||||||
|
session has no `token_usage` rows. The editor's `state.vscdb` keeps a
|
||||||
|
`tokenCount` per chat bubble and a display model name (`GPT`, `default`, a BYOK
|
||||||
|
name); AgentHub does not read it. No Cursor hooks are installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Agent Extension Layer
|
## Agent Extension Layer
|
||||||
|
|
||||||
The repository includes a triple extension strategy:
|
The repository includes a triple extension strategy:
|
||||||
@@ -2271,8 +2301,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
|
||||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
|
||||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||||
|
|||||||
+4
-3
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
|||||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
`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
|
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||||
`brew upgrade` or `nvm` switch deletes that binary.
|
`brew upgrade` or `nvm` switch deletes that binary.
|
||||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
|
||||||
service install`), so the dashboard comes back after every login with no
|
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
|
||||||
|
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
|
||||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ Internal build — all rights reserved.
|
|||||||
other its conversation with a prompt box — sending a message resumes it,
|
other its conversation with a prompt box — sending a message resumes it,
|
||||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||||
prompt box that starts a new session, Recents, and the workspace's own
|
prompt box that starts a new session, Recents, and the workspace's own
|
||||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
||||||
sessions start with.
|
and extra Folders, which Claude and Codex sessions start with. Files and
|
||||||
|
images dragged onto a terminal or a prompt box are uploaded and attached by
|
||||||
|
their saved path.
|
||||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||||
@@ -78,7 +80,7 @@ Internal build — all rights reserved.
|
|||||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||||
Per-browser only.
|
Per-browser only.
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||||
@@ -86,6 +88,16 @@ Internal build — all rights reserved.
|
|||||||
notification type can pick its own.
|
notification type can pick its own.
|
||||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||||
a UI in English, Vietnamese, Chinese and Korean.
|
a UI in English, Vietnamese, Chinese and Korean.
|
||||||
|
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
||||||
|
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
|
||||||
|
Claude Code statusline with session and today's cost
|
||||||
|
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
||||||
|
Prometheus rules + Grafana boards for `/api/metrics`
|
||||||
|
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
||||||
|
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
||||||
|
every page and API call, with admin / member / analyst roles. It is off by
|
||||||
|
default, and a local install does not change. It is login only, without
|
||||||
|
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -197,7 +209,10 @@ agenthub service uninstall # disable and remove it
|
|||||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||||
once yourself. Plugin installs get the same persistence automatically — see
|
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||||
|
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||||
|
unit and never stops the running server). Plugin installs get it only once
|
||||||
|
that switch is on — see
|
||||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||||
|
|
||||||
## The CLI
|
## The CLI
|
||||||
@@ -268,6 +283,13 @@ for a session the hooks report on (a long tool call writes nothing, and after a
|
|||||||
Stop agy may still be waiting for you), after two minutes for one without
|
Stop agy may still be waiting for you), after two minutes for one without
|
||||||
hooks. Antigravity workspaces carry no pipeline either.
|
hooks. Antigravity workspaces carry no pipeline either.
|
||||||
|
|
||||||
|
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
||||||
|
read from Cursor's agent transcripts
|
||||||
|
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
||||||
|
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
||||||
|
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||||
|
cost.
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||||
@@ -314,7 +336,13 @@ npm run test:server # node:test
|
|||||||
npm run test:client # Vitest
|
npm run test:client # Vitest
|
||||||
```
|
```
|
||||||
|
|
||||||
Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
||||||
|
runs Prettier, then the tests the commit needs: both full suites for a big or
|
||||||
|
risky commit (over 10 code files, a dependency, build/test config or the hook
|
||||||
|
itself, a deleted file), only the related tests for a small one (`vitest
|
||||||
|
related` for `client/`, the server test files that name each other staged
|
||||||
|
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
||||||
|
both full suites.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -323,6 +351,8 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
|||||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||||
| `bin/agenthub.js` | CLI |
|
| `bin/agenthub.js` | CLI |
|
||||||
|
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||||
|
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||||
| `docs/` | Architecture, API, workspaces, database |
|
| `docs/` | Architecture, API, workspaces, database |
|
||||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
|
|||||||
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
|
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
|
||||||
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
|
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
|
||||||
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
|
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
|
||||||
Bản cài qua plugin cũng được tự khởi động tương tự — xem
|
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
|
||||||
|
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
|
||||||
|
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
|
||||||
|
bật — xem
|
||||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
|
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
|
||||||
`docs/PLUGINS.md`.
|
`docs/PLUGINS.md`.
|
||||||
|
|
||||||
@@ -269,8 +272,13 @@ npm run test:server # node:test
|
|||||||
npm run test:client # Vitest
|
npm run test:client # Vitest
|
||||||
```
|
```
|
||||||
|
|
||||||
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
|
Cả hai phải xanh (pass) trước khi commit. Pre-commit hook (`.husky/pre-commit`)
|
||||||
thêm Prettier.
|
chạy Prettier, rồi chạy những test mà commit cần: cả hai bộ đầy đủ cho commit
|
||||||
|
lớn hoặc rủi ro (hơn 10 file code, đổi dependency, cấu hình build/test hay
|
||||||
|
chính hook, có xoá file), chỉ test liên quan cho commit nhỏ (`vitest related`
|
||||||
|
cho `client/`, các file test server có nhắc tới module được stage), và không
|
||||||
|
chạy test nào nếu chỉ sửa tài liệu. `AGENTHUB_FULL_TESTS=1 git commit …` buộc
|
||||||
|
chạy cả hai bộ đầy đủ.
|
||||||
|
|
||||||
## Cấu trúc thư mục
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
|||||||
+11
-11
File diff suppressed because one or more lines are too long
+350
-15
@@ -141,21 +141,10 @@ function termWidth() {
|
|||||||
/**
|
/**
|
||||||
* Resolve the dashboard base URL. Env override first (matches the hook
|
* Resolve the dashboard base URL. Env override first (matches the hook
|
||||||
* handler's contract), then the live-server discovery file, then the default
|
* handler's contract), then the live-server discovery file, then the default
|
||||||
* port. The discovery module is best-effort and never throws.
|
* port — shared with the MCP server and the statusline (`dashboard-url.js`).
|
||||||
*/
|
*/
|
||||||
function baseUrl() {
|
function baseUrl() {
|
||||||
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
|
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||||
if (envPort) return `http://127.0.0.1:${envPort}`;
|
|
||||||
try {
|
|
||||||
const { resolveDashboardPort } = require(
|
|
||||||
path.join(REPO_ROOT, "server", "lib", "server-info.js")
|
|
||||||
);
|
|
||||||
const port = resolveDashboardPort();
|
|
||||||
if (port) return `http://127.0.0.1:${port}`;
|
|
||||||
} catch {
|
|
||||||
/* discovery unavailable — fall through to the default */
|
|
||||||
}
|
|
||||||
return "http://127.0.0.1:4820";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||||
@@ -171,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
const form = body instanceof FormData;
|
const form = body instanceof FormData;
|
||||||
res = await fetch(url, {
|
res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
|
headers: {
|
||||||
|
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||||
|
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||||
|
...(process.env.AGENTHUB_PANE_TOKEN
|
||||||
|
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||||
});
|
});
|
||||||
@@ -1037,6 +1032,8 @@ async function cmdRemoteSources(flags, positional) {
|
|||||||
ssh_port: flags.port != null ? Number(flags.port) : null,
|
ssh_port: flags.port != null ? Number(flags.port) : null,
|
||||||
identity_file: flags.identity != null ? String(flags.identity) : null,
|
identity_file: flags.identity != null ? String(flags.identity) : null,
|
||||||
remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
|
remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
|
||||||
|
sync_codex: !!flags.codex,
|
||||||
|
remote_codex_home: flags["codex-home"] != null ? String(flags["codex-home"]) : null,
|
||||||
enabled: flags.disabled ? false : true,
|
enabled: flags.disabled ? false : true,
|
||||||
};
|
};
|
||||||
const r = await post("/api/remote-sources", body);
|
const r = await post("/api/remote-sources", body);
|
||||||
@@ -2312,6 +2309,17 @@ async function cmdLockAcquire(args) {
|
|||||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only 409 means "held by someone else"; any other answer (a refused
|
||||||
|
// name, a server error) will not change by waiting.
|
||||||
|
if (result.status !== 409) {
|
||||||
|
console.error(
|
||||||
|
c.red(
|
||||||
|
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Date.now() - lastPrinted >= 60_000) {
|
if (Date.now() - lastPrinted >= 60_000) {
|
||||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -2728,6 +2736,21 @@ const COMMAND_GROUPS = [
|
|||||||
"install|uninstall|status [--port N]",
|
"install|uninstall|status [--port N]",
|
||||||
"Auto-start on boot via systemd --user (Linux)",
|
"Auto-start on boot via systemd --user (Linux)",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"server",
|
||||||
|
"setup|off|status [--hostname name] [--admin name]",
|
||||||
|
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"users",
|
||||||
|
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||||
|
"Server mode's accounts (add: --role admin|analyst|member)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"accounts",
|
||||||
|
"list|add|remove [name]",
|
||||||
|
"Claude account pool: new sessions spread over these accounts",
|
||||||
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2797,7 +2820,7 @@ const COMMAND_GROUPS = [
|
|||||||
[
|
[
|
||||||
"remote-sources add",
|
"remote-sources add",
|
||||||
"",
|
"",
|
||||||
"--label X --host user@host [--port N --identity path --remote-home path]",
|
"--label X --host user@host [--port N --identity path --remote-home path --codex --codex-home path]",
|
||||||
],
|
],
|
||||||
["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
|
["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
|
||||||
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
|
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
|
||||||
@@ -2917,6 +2940,22 @@ const COMMAND_GROUPS = [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"Integrations",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"mcp",
|
||||||
|
"",
|
||||||
|
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
||||||
|
],
|
||||||
|
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
||||||
|
[
|
||||||
|
"statusline install|uninstall",
|
||||||
|
"[--force]",
|
||||||
|
"Add/remove AgentHub's statusLine in ~/.claude/settings.json (--force: replace or remove another one)",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"Administration",
|
"Administration",
|
||||||
[
|
[
|
||||||
@@ -3661,11 +3700,291 @@ const COMMANDS = (() => {
|
|||||||
const SUBCOMMANDS = {
|
const SUBCOMMANDS = {
|
||||||
alerts: ["ack", "ack-all"],
|
alerts: ["ack", "ack-all"],
|
||||||
service: ["install", "uninstall", "status"],
|
service: ["install", "uninstall", "status"],
|
||||||
|
server: ["setup", "off", "status"],
|
||||||
|
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||||
|
accounts: ["list", "add", "remove"],
|
||||||
pricing: ["set", "delete", "reset"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
import: ["rescan", "path"],
|
import: ["rescan", "path"],
|
||||||
|
statusline: ["install", "uninstall"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `agenthub statusline install|uninstall [--force]` — the user's settings.json. */
|
||||||
|
function cmdStatusline(sub, flags) {
|
||||||
|
const { install, uninstall } = require(path.join(REPO_ROOT, "statusline", "install.js"));
|
||||||
|
const { getSettingsPath } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||||
|
const file = getSettingsPath();
|
||||||
|
const force = Boolean(flags.force);
|
||||||
|
const r = sub === "install" ? install(file, { force }) : uninstall(file, { force });
|
||||||
|
const prev = r.previous ? JSON.stringify(r.previous) : "";
|
||||||
|
switch (r.status) {
|
||||||
|
case "installed":
|
||||||
|
console.log(`${c.green("✔")} statusLine set in ${file}: ${r.command}`);
|
||||||
|
if (prev) console.log(c.dim(` replaced ${prev} (backup beside the file)`));
|
||||||
|
console.log(c.dim(" Claude Code picks it up on the next prompt."));
|
||||||
|
return;
|
||||||
|
case "unchanged":
|
||||||
|
console.log(`statusLine already set in ${file}: ${r.command}`);
|
||||||
|
return;
|
||||||
|
case "removed":
|
||||||
|
console.log(`${c.green("✔")} statusLine removed from ${file} (backup beside the file)`);
|
||||||
|
return;
|
||||||
|
case "absent":
|
||||||
|
console.log(`No statusLine in ${file}.`);
|
||||||
|
return;
|
||||||
|
case "foreign":
|
||||||
|
console.error(c.red(`✖ ${file} has a statusLine that is not AgentHub's: ${prev}`));
|
||||||
|
console.error(
|
||||||
|
c.dim(
|
||||||
|
` Re-run with --force to ${sub === "install" ? "replace" : "remove"} it (a backup is kept).`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server mode (docs/SERVER-MODE.md) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** The auth module over the SAME database and settings the server reads: the
|
||||||
|
* repo `.env`, then AgentHub's settings file (a pinned DASHBOARD_DATA_DIR). */
|
||||||
|
function serverAuth() {
|
||||||
|
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
|
||||||
|
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
|
||||||
|
return { settingsEnv, auth: require(path.join(REPO_ROOT, "server", "lib", "auth.js")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One password: typed hidden on a terminal, else the first line of stdin. */
|
||||||
|
async function askPassword(prompt) {
|
||||||
|
const stdin = process.stdin;
|
||||||
|
if (!stdin.isTTY) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stdin) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/)[0];
|
||||||
|
}
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let typed = "";
|
||||||
|
const finish = () => {
|
||||||
|
stdin.setRawMode(false);
|
||||||
|
stdin.pause();
|
||||||
|
stdin.off("data", onData);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
};
|
||||||
|
const onData = (text) => {
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === "\r" || ch === "\n") return (finish(), resolve(typed));
|
||||||
|
if (ch === "\u0003") return (finish(), reject(new Error("cancelled")));
|
||||||
|
typed = ch === "\u007f" || ch === "\b" ? typed.slice(0, -1) : typed + ch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stdin.setRawMode(true);
|
||||||
|
stdin.setEncoding("utf8");
|
||||||
|
stdin.on("data", onData);
|
||||||
|
stdin.resume();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new password, typed twice on a terminal. */
|
||||||
|
async function newPassword(label) {
|
||||||
|
const first = await askPassword(`${label}: `);
|
||||||
|
if (process.stdin.isTTY && (await askPassword("Again: ")) !== first) {
|
||||||
|
throw new Error("the two passwords differ");
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||||
|
async function cmdServer(sub, flags) {
|
||||||
|
const { settingsEnv, auth } = serverAuth();
|
||||||
|
const file = settingsEnv.settingsFilePath();
|
||||||
|
const envNote = (key) => {
|
||||||
|
if (settingsEnv.envOwned.has(key)) {
|
||||||
|
console.log(
|
||||||
|
c.yellow(`! ${key} is also set in the environment or .env, which wins over ${file}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (sub === "setup") {
|
||||||
|
if (flags.hostname) {
|
||||||
|
const hosts = String(flags.hostname);
|
||||||
|
if (!/^[a-z0-9.\-:[\],]+$/i.test(hosts)) throw new Error(`bad --hostname: ${hosts}`);
|
||||||
|
settingsEnv.writeSetting("DASHBOARD_ALLOWED_HOSTS", hosts);
|
||||||
|
envNote("DASHBOARD_ALLOWED_HOSTS");
|
||||||
|
}
|
||||||
|
if (!auth.hasAdmin()) {
|
||||||
|
const name = typeof flags.admin === "string" ? flags.admin : "admin";
|
||||||
|
const user = await auth.createUser(
|
||||||
|
name,
|
||||||
|
await newPassword(`Password for admin ${name}`),
|
||||||
|
"admin"
|
||||||
|
);
|
||||||
|
console.log(`${c.green("✔")} admin ${user.username} created`);
|
||||||
|
} else {
|
||||||
|
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||||
|
}
|
||||||
|
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||||
|
envNote("AGENTHUB_MODE");
|
||||||
|
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||||
|
console.log(" Next:");
|
||||||
|
console.log(
|
||||||
|
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||||
|
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||||
|
);
|
||||||
|
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||||
|
console.log(
|
||||||
|
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "off") {
|
||||||
|
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
|
||||||
|
envNote("AGENTHUB_MODE");
|
||||||
|
console.log(`${c.green("✔")} server mode off — restart the dashboard. Users are kept.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "status" || sub === undefined) {
|
||||||
|
const users = auth.listUsers();
|
||||||
|
console.log(`mode: ${auth.isServerMode() ? "server" : "local"}`);
|
||||||
|
console.log(`settings: ${file}`);
|
||||||
|
console.log(
|
||||||
|
`users: ${users.length} (${users.filter((u) => u.role === "admin").length} admin)`
|
||||||
|
);
|
||||||
|
console.log(c.dim(" a change takes effect when the dashboard restarts"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||||
|
async function cmdUsers(sub, positional, flags) {
|
||||||
|
const { auth } = serverAuth();
|
||||||
|
const name = positional[0];
|
||||||
|
const need = () => {
|
||||||
|
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
|
||||||
|
};
|
||||||
|
switch (sub) {
|
||||||
|
case undefined:
|
||||||
|
case "list":
|
||||||
|
table(
|
||||||
|
["id", "username", "role", "state"],
|
||||||
|
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "add": {
|
||||||
|
need();
|
||||||
|
const role = typeof flags.role === "string" ? flags.role : "member";
|
||||||
|
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||||
|
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "passwd":
|
||||||
|
need();
|
||||||
|
await auth.setPassword(name, await newPassword(`New password for ${name}`));
|
||||||
|
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
|
||||||
|
return;
|
||||||
|
case "role":
|
||||||
|
need();
|
||||||
|
auth.setRole(name, positional[1]);
|
||||||
|
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
|
||||||
|
return;
|
||||||
|
case "assign":
|
||||||
|
if (!name || !positional[1]) {
|
||||||
|
throw new Error("usage: agenthub users assign <workspace id> <username>");
|
||||||
|
}
|
||||||
|
auth.assignWorkspace(name, positional[1]);
|
||||||
|
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
|
||||||
|
return;
|
||||||
|
case "audit": {
|
||||||
|
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
|
||||||
|
table(
|
||||||
|
["when", "reader", "owner", "path"],
|
||||||
|
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
|
||||||
|
a.at.replace("T", " ").slice(0, 19),
|
||||||
|
a.reader,
|
||||||
|
a.owner,
|
||||||
|
a.path,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
case "enable":
|
||||||
|
need();
|
||||||
|
auth.setDisabled(name, sub === "disable");
|
||||||
|
console.log(`${c.green("✔")} ${name} ${sub}d`);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.error(
|
||||||
|
c.red(
|
||||||
|
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|member] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||||
|
async function cmdAccounts(sub, positional) {
|
||||||
|
serverAuth(); // the same settings + database the server reads
|
||||||
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||||
|
const name = positional[0];
|
||||||
|
if (sub === "add" || sub === "remove") {
|
||||||
|
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
|
||||||
|
}
|
||||||
|
if (sub === "add") {
|
||||||
|
console.log(
|
||||||
|
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||||
|
);
|
||||||
|
const added = pool.addAccount(name, await askPassword(`Token for ${name}: `));
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} ${added.name} added — valid until ${added.expiresAt.slice(0, 10)}`
|
||||||
|
);
|
||||||
|
console.log(c.dim(" New Claude sessions now spread over the pool's accounts."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "remove") {
|
||||||
|
pool.removeAccount(name);
|
||||||
|
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === undefined || sub === "list") {
|
||||||
|
await pool.refreshQuotas({ force: true });
|
||||||
|
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||||
|
table(
|
||||||
|
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
|
||||||
|
pool
|
||||||
|
.poolStatus()
|
||||||
|
.map((a) => [
|
||||||
|
a.name,
|
||||||
|
a.livePanes,
|
||||||
|
a.spend5h.toFixed(2),
|
||||||
|
a.spend7d.toFixed(2),
|
||||||
|
pct(a.quota?.fiveHour),
|
||||||
|
pct(a.quota?.sevenDay),
|
||||||
|
a.expired
|
||||||
|
? "expired"
|
||||||
|
: a.full
|
||||||
|
? "full"
|
||||||
|
: a.quota?.error
|
||||||
|
? `no quota (${a.quota.error})`
|
||||||
|
: "ok",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Run one parsed command. Returns the handler's promise; may throw
|
/** Run one parsed command. Returns the handler's promise; may throw
|
||||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
@@ -3683,6 +4002,12 @@ async function runCommand(argv) {
|
|||||||
return cmdStart(flags);
|
return cmdStart(flags);
|
||||||
case "service":
|
case "service":
|
||||||
return cmdService(positional[0], flags);
|
return cmdService(positional[0], flags);
|
||||||
|
case "server":
|
||||||
|
return cmdServer(positional[0], flags);
|
||||||
|
case "users":
|
||||||
|
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||||
|
case "accounts":
|
||||||
|
return cmdAccounts(positional[0], positional.slice(1));
|
||||||
case "stats":
|
case "stats":
|
||||||
return cmdStats();
|
return cmdStats();
|
||||||
case "kanban":
|
case "kanban":
|
||||||
@@ -3800,6 +4125,16 @@ async function runCommand(argv) {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "mcp":
|
||||||
|
return require(path.join(REPO_ROOT, "mcp", "server.js")).main();
|
||||||
|
case "statusline":
|
||||||
|
if (rest[0] === "install" || rest[0] === "uninstall") return cmdStatusline(rest[0], flags);
|
||||||
|
if (rest[0]) {
|
||||||
|
console.error("usage: agenthub statusline [install|uninstall [--force]]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||||
case "open":
|
case "open":
|
||||||
return cmdOpen();
|
return cmdOpen();
|
||||||
case "version":
|
case "version":
|
||||||
|
|||||||
+20
-8
@@ -52,13 +52,13 @@ The client is a single-page application (SPA) built with modern web technologies
|
|||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **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/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) 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|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — 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.
|
**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`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), 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:
|
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.
|
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||||
- 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 three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--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.
|
- `--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.
|
- 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.
|
||||||
@@ -199,13 +199,17 @@ client/
|
|||||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||||
|
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
||||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||||
│ │
|
│ │
|
||||||
│ ├── pages/ # Route pages
|
│ ├── pages/ # Route pages
|
||||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||||||
|
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||||||
|
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
|
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
||||||
|
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||||
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
||||||
│ │ ├── Settings.tsx
|
│ │ ├── Settings.tsx
|
||||||
@@ -255,7 +259,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
|
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
||||||
|
|
||||||
App --> Layout
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||||
@@ -501,6 +505,8 @@ function App() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="sessions" element={<History />} />
|
<Route path="sessions" element={<History />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
|
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
|
||||||
|
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||||
@@ -519,7 +525,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||||
|
|
||||||
### Navigation Flow
|
### Navigation Flow
|
||||||
|
|
||||||
@@ -787,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
|||||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||||
|
|
||||||
|
#### Metric tooltips (`common:insights.help.*`)
|
||||||
|
|
||||||
|
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
|
||||||
|
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
|
||||||
|
`vi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Utilities
|
## Utilities
|
||||||
|
|||||||
Generated
+112
@@ -8,6 +8,10 @@
|
|||||||
"name": "agenthub-client",
|
"name": "agenthub-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.11.1",
|
||||||
|
"@codemirror/merge": "^6.12.2",
|
||||||
|
"@codemirror/state": "^6.7.6",
|
||||||
|
"@codemirror/view": "^6.43.13",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
@@ -410,6 +414,66 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||||
|
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||||
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/merge": {
|
||||||
|
"version": "6.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||||
|
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"style-mod": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||||
|
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||||
|
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
@@ -1052,6 +1116,36 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -2580,6 +2674,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -5702,6 +5802,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
@@ -6402,6 +6508,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.11.1",
|
||||||
|
"@codemirror/merge": "^6.12.2",
|
||||||
|
"@codemirror/state": "^6.7.6",
|
||||||
|
"@codemirror/view": "^6.43.13",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
|
|||||||
+9
-21
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @description Service Worker for caching static assets and handling push notifications.
|
* @description Service Worker: network-first navigations with an offline fallback, and push
|
||||||
|
* notifications. Hashed `/assets/` bundles are left to the HTTP cache (immutable).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Bump this any time the SW logic changes - old clients will install the new
|
// Bump this any time the SW logic changes - old clients will install the new
|
||||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||||
// freshly-built bundle starts being served on the very next request.
|
// freshly-built bundle starts being served on the very next request.
|
||||||
const CACHE_NAME = "dashboard-v2";
|
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
|
||||||
|
// and were never pruned.
|
||||||
|
const CACHE_NAME = "dashboard-v3";
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
// No pre-cache: network-first below means the cache fills lazily, and
|
// No pre-cache: network-first below means the cache fills lazily, and
|
||||||
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
// Hashed bundles under /assets/ are immutable and the server marks them so:
|
||||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
// the browser's HTTP cache keeps them. Caching them here too only grew a
|
||||||
// don't get re-requested.
|
// second copy of every build, forever.
|
||||||
if (url.pathname.startsWith("/assets/")) {
|
if (url.pathname.startsWith("/assets/")) return;
|
||||||
event.respondWith(
|
|
||||||
caches.match(request).then(
|
|
||||||
(cached) =>
|
|
||||||
cached ||
|
|
||||||
fetch(request).then((response) => {
|
|
||||||
if (response.ok && response.type === "basic") {
|
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||||
// with cache fallback. The user always gets the freshest UI while online and
|
// with cache fallback. The user always gets the freshest UI while online and
|
||||||
|
|||||||
+46
-7
@@ -38,8 +38,8 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
* - `./pages/Dashboard`
|
* - `./pages/Dashboard` (lazy)
|
||||||
* - `./pages/History`
|
* - `./pages/History` (lazy)
|
||||||
* - `./pages/SessionDetail` (lazy)
|
* - `./pages/SessionDetail` (lazy)
|
||||||
* - `./pages/SettingsHub` (lazy)
|
* - `./pages/SettingsHub` (lazy)
|
||||||
* - `./pages/Workspace` (lazy)
|
* - `./pages/Workspace` (lazy)
|
||||||
@@ -64,16 +64,19 @@ import { GlobalTooltip } from "./components/Tooltip";
|
|||||||
import { Toaster } from "./components/Toaster";
|
import { Toaster } from "./components/Toaster";
|
||||||
import { ConfirmHost } from "./components/ConfirmHost";
|
import { ConfirmHost } from "./components/ConfirmHost";
|
||||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
|
||||||
import { History } from "./pages/History";
|
|
||||||
import { NotFound } from "./pages/NotFound";
|
import { NotFound } from "./pages/NotFound";
|
||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { useNotifications } from "./hooks/useNotifications";
|
import { useNotifications } from "./hooks/useNotifications";
|
||||||
import { eventBus } from "./lib/eventBus";
|
import { eventBus } from "./lib/eventBus";
|
||||||
|
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||||
|
import { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||||
import type { WSMessage } from "./lib/types";
|
import type { WSMessage } from "./lib/types";
|
||||||
|
|
||||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||||
// visit instead of in the initial bundle.
|
// visit instead of in the initial bundle — the Dashboard and History too, so the
|
||||||
|
// entry chunk holds only the shell.
|
||||||
|
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||||
|
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
|
||||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||||
const SettingsHub = lazy(() =>
|
const SettingsHub = lazy(() =>
|
||||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||||
@@ -81,6 +84,9 @@ const SettingsHub = lazy(() =>
|
|||||||
const SessionDetail = lazy(() =>
|
const SessionDetail = lazy(() =>
|
||||||
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
|
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
|
||||||
);
|
);
|
||||||
|
const SnapshotView = lazy(() =>
|
||||||
|
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||||
|
);
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -128,6 +134,16 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The server came back as a newer version (any tab, any way it was updated):
|
||||||
|
// load its UI build instead of leaving the user to press F5.
|
||||||
|
useEffect(() => reloadOnNewBuild(), []);
|
||||||
|
|
||||||
|
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||||
|
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SplashScreen />
|
<SplashScreen />
|
||||||
@@ -141,8 +157,22 @@ export default function App() {
|
|||||||
<DocumentTitle />
|
<DocumentTitle />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout wsConnected={connected} />}>
|
<Route element={<Layout wsConnected={connected} />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route
|
||||||
<Route path="sessions" element={<History />} />
|
index
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<Dashboard />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="sessions"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<History />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="sessions/:id"
|
path="sessions/:id"
|
||||||
element={
|
element={
|
||||||
@@ -151,6 +181,15 @@ export default function App() {
|
|||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="snapshot/:token"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<SnapshotView />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="workflows"
|
path="workflows"
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ import {
|
|||||||
BellOff,
|
BellOff,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
Eye,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -87,9 +89,11 @@ import { ConfirmModal } from "./ConfirmModal";
|
|||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
import { FieldHelp } from "./FieldHelp";
|
import { FieldHelp } from "./FieldHelp";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||||
import { timeAgo } from "../lib/format";
|
import { fmtCost, timeAgo } from "../lib/format";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
|
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -124,6 +128,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
|||||||
"inactivity",
|
"inactivity",
|
||||||
"status_duration",
|
"status_duration",
|
||||||
"token_threshold",
|
"token_threshold",
|
||||||
|
"cost_threshold",
|
||||||
|
"budget",
|
||||||
];
|
];
|
||||||
|
|
||||||
type TabKey = "rules" | "channels" | "activity";
|
type TabKey = "rules" | "channels" | "activity";
|
||||||
@@ -139,6 +145,11 @@ interface RuleFormState {
|
|||||||
minutes: string;
|
minutes: string;
|
||||||
status: "working" | "waiting";
|
status: "working" | "waiting";
|
||||||
total_tokens: string;
|
total_tokens: string;
|
||||||
|
usd: string;
|
||||||
|
period: "day" | "week" | "month";
|
||||||
|
limit_usd: string;
|
||||||
|
thresholds: string;
|
||||||
|
budget_tool: string;
|
||||||
cooldown_seconds: string;
|
cooldown_seconds: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +164,11 @@ const EMPTY_FORM: RuleFormState = {
|
|||||||
minutes: "10",
|
minutes: "10",
|
||||||
status: "working",
|
status: "working",
|
||||||
total_tokens: "1000000",
|
total_tokens: "1000000",
|
||||||
|
usd: "5",
|
||||||
|
period: "month",
|
||||||
|
limit_usd: "100",
|
||||||
|
thresholds: "80, 100",
|
||||||
|
budget_tool: "",
|
||||||
cooldown_seconds: "300",
|
cooldown_seconds: "300",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,12 +187,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
|
|||||||
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
||||||
status: c.status === "waiting" ? "waiting" : "working",
|
status: c.status === "waiting" ? "waiting" : "working",
|
||||||
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
||||||
|
usd: str(c.usd, EMPTY_FORM.usd),
|
||||||
|
period: c.period === "day" || c.period === "week" ? c.period : "month",
|
||||||
|
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
|
||||||
|
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
|
||||||
|
budget_tool: str(c.tool, ""),
|
||||||
cooldown_seconds: String(rule.cooldown_seconds),
|
cooldown_seconds: String(rule.cooldown_seconds),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||||
|
|
||||||
|
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
|
||||||
|
function parseThresholds(text: string): number[] | null {
|
||||||
|
const parts = text
|
||||||
|
.split(",")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const nums = parts.map((p) => Number(p));
|
||||||
|
if (nums.length === 0 || nums.length > 10) return null;
|
||||||
|
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||||
switch (form.rule_type) {
|
switch (form.rule_type) {
|
||||||
case "event_pattern": {
|
case "event_pattern": {
|
||||||
@@ -198,6 +230,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
|||||||
return { status: form.status, minutes: parseFloat(form.minutes) };
|
return { status: form.status, minutes: parseFloat(form.minutes) };
|
||||||
case "token_threshold":
|
case "token_threshold":
|
||||||
return { total_tokens: parseInt(form.total_tokens, 10) };
|
return { total_tokens: parseInt(form.total_tokens, 10) };
|
||||||
|
case "cost_threshold":
|
||||||
|
return { usd: parseFloat(form.usd) };
|
||||||
|
case "budget": {
|
||||||
|
const config: AlertRule["config"] = {
|
||||||
|
period: form.period,
|
||||||
|
limit_usd: parseFloat(form.limit_usd),
|
||||||
|
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
|
||||||
|
tz_offset: new Date().getTimezoneOffset(),
|
||||||
|
};
|
||||||
|
if (form.budget_tool) config.tool = form.budget_tool;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +269,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
|||||||
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
||||||
case "token_threshold":
|
case "token_threshold":
|
||||||
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
||||||
|
case "cost_threshold":
|
||||||
|
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
|
||||||
|
case "budget":
|
||||||
|
return t("ruleDesc.budget", {
|
||||||
|
limit: fmtCost(c.limit_usd ?? 0),
|
||||||
|
period: t(`periods.${c.period ?? "month"}`),
|
||||||
|
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +296,9 @@ export function AlertsNotifications() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||||
|
const [preview, setPreview] = useState<RulePreview | null>(null);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const aiTools = useAiTools();
|
||||||
const ruleCtx = useContextMenu<AlertRule>();
|
const ruleCtx = useContextMenu<AlertRule>();
|
||||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||||
@@ -288,6 +343,7 @@ export function AlertsNotifications() {
|
|||||||
setLoadingAlerts(false);
|
setLoadingAlerts(false);
|
||||||
}
|
}
|
||||||
}, [unackedOnly, t]);
|
}, [unackedOnly, t]);
|
||||||
|
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -322,10 +378,35 @@ export function AlertsNotifications() {
|
|||||||
});
|
});
|
||||||
}, [loadAlerts]);
|
}, [loadAlerts]);
|
||||||
|
|
||||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
// Any edit makes a shown preview stale.
|
||||||
|
const set = (patch: Partial<RuleFormState>) => {
|
||||||
|
setPreview(null);
|
||||||
|
setForm((prev) => ({ ...prev, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreview = async () => {
|
||||||
|
if (previewing) return;
|
||||||
|
setPreviewing(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||||
|
setPreview(
|
||||||
|
await api.alerts.rules.preview({
|
||||||
|
rule_type: form.rule_type,
|
||||||
|
config: buildConfig(form),
|
||||||
|
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeForm = () => {
|
const closeForm = () => {
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
|
setPreview(null);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -333,6 +414,7 @@ export function AlertsNotifications() {
|
|||||||
|
|
||||||
const openEditRule = (rule: AlertRule) => {
|
const openEditRule = (rule: AlertRule) => {
|
||||||
setForm(formFromRule(rule));
|
setForm(formFromRule(rule));
|
||||||
|
setPreview(null);
|
||||||
setEditingId(rule.id);
|
setEditingId(rule.id);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
@@ -417,7 +499,10 @@ export function AlertsNotifications() {
|
|||||||
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
||||||
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
||||||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
||||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
|
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
|
||||||
|
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
|
||||||
|
(form.rule_type !== "budget" ||
|
||||||
|
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||||
|
|
||||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||||
{
|
{
|
||||||
@@ -680,6 +765,100 @@ export function AlertsNotifications() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{form.rule_type === "cost_threshold" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<label className="block text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("rules.form.usd")}
|
||||||
|
<FieldHelp description={t("rules.help.usd")} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={form.usd}
|
||||||
|
onChange={(e) => set({ usd: e.target.value })}
|
||||||
|
className="input mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.rule_type === "budget" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||||
|
<label className="block text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("rules.form.period")}
|
||||||
|
<FieldHelp description={t("rules.help.period")} />
|
||||||
|
</span>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<select
|
||||||
|
value={form.period}
|
||||||
|
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
|
||||||
|
className="input w-full appearance-none pr-8"
|
||||||
|
>
|
||||||
|
{(["day", "week", "month"] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{t(`periods.${p}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("rules.form.limitUsd")}
|
||||||
|
<FieldHelp description={t("rules.help.limitUsd")} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={form.limit_usd}
|
||||||
|
onChange={(e) => set({ limit_usd: e.target.value })}
|
||||||
|
className="input mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("rules.form.thresholds")}
|
||||||
|
<FieldHelp description={t("rules.help.thresholds")} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.thresholds}
|
||||||
|
onChange={(e) => set({ thresholds: e.target.value })}
|
||||||
|
placeholder="80, 100"
|
||||||
|
className="input mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{t("rules.form.budgetTool")}
|
||||||
|
<FieldHelp description={t("rules.help.budgetTool")} />
|
||||||
|
</span>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<select
|
||||||
|
value={form.budget_tool}
|
||||||
|
onChange={(e) => set({ budget_tool: e.target.value })}
|
||||||
|
className="input w-full appearance-none pr-8"
|
||||||
|
>
|
||||||
|
<option value="">{t("rules.form.allTools")}</option>
|
||||||
|
{aiTools.tools.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && <RulePreviewResult preview={preview} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<label className="block text-xs text-fg-secondary">
|
<label className="block text-xs text-fg-secondary">
|
||||||
<span className="mb-1.5 flex items-center gap-1">
|
<span className="mb-1.5 flex items-center gap-1">
|
||||||
@@ -701,15 +880,30 @@ export function AlertsNotifications() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onPreview}
|
||||||
|
disabled={!canSubmit || previewing}
|
||||||
|
title={t("rules.previewHint")}
|
||||||
|
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
{previewing ? t("rules.previewing") : t("rules.preview")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSaveRule}
|
onClick={onSaveRule}
|
||||||
disabled={!canSubmit || saving}
|
disabled={!canSubmit || saving}
|
||||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
{editingId ? (
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -825,11 +1019,13 @@ export function AlertsNotifications() {
|
|||||||
label={t("feed.unackedOnly")}
|
label={t("feed.unackedOnly")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadAlerts()}
|
onClick={refreshAlerts}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||||
title={t("refresh")}
|
title={t("refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{unacked > 0 && (
|
{unacked > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -927,3 +1123,51 @@ export function AlertsNotifications() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
|
||||||
|
function RulePreviewResult({ preview }: { preview: RulePreview }) {
|
||||||
|
const { t } = useTranslation("alerts");
|
||||||
|
if (!preview.supported) {
|
||||||
|
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
|
||||||
|
}
|
||||||
|
if (preview.periods) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||||
|
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
|
||||||
|
{preview.periods.map((p) => (
|
||||||
|
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
|
||||||
|
<span className="text-fg-muted">{p.period_start}</span>
|
||||||
|
<span className="text-fg-secondary">
|
||||||
|
{fmtCost(p.spend)} · {Math.round(p.pct)}%
|
||||||
|
</span>
|
||||||
|
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
|
||||||
|
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||||
|
<p className="text-fg-secondary">
|
||||||
|
{t("rules.previewResult", {
|
||||||
|
fires: preview.fires,
|
||||||
|
sessions: preview.sessions,
|
||||||
|
days: preview.window_days,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{preview.samples.map((s, i) => (
|
||||||
|
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
|
||||||
|
<Link
|
||||||
|
to={`/sessions/${encodeURIComponent(s.session_id)}`}
|
||||||
|
className="truncate hover:text-accent"
|
||||||
|
>
|
||||||
|
{s.session_id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
<span>{timeAgo(s.at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* @file AuthGate.tsx
|
||||||
|
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
|
||||||
|
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
|
||||||
|
* mode — the default — it renders the app and nothing else changes, at once
|
||||||
|
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||||
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||||
|
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||||
|
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||||
|
|
||||||
|
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||||
|
|
||||||
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||||
|
const MODE_KEY = "agenthub.authMode";
|
||||||
|
const RETRY_MS = 2000;
|
||||||
|
|
||||||
|
function cachedState(): AuthState | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: { children: ReactNode }) {
|
||||||
|
// null = still asking the server. A browser that last saw local mode starts
|
||||||
|
// there and is corrected by the answer (or by a 401) if that changed.
|
||||||
|
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
let alive = true;
|
||||||
|
const ask = () => {
|
||||||
|
api.auth
|
||||||
|
.me()
|
||||||
|
.then((next) => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MODE_KEY, next.mode);
|
||||||
|
} catch {
|
||||||
|
/* storage blocked */
|
||||||
|
}
|
||||||
|
setState(next);
|
||||||
|
})
|
||||||
|
.catch((err: { status?: number; code?: string }) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// An answer that is not this build's: an older server still running
|
||||||
|
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||||
|
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||||
|
// app as before and let it show its own state.
|
||||||
|
if (
|
||||||
|
err?.status === 404 ||
|
||||||
|
err?.code === "EBADRESPONSE" ||
|
||||||
|
(err?.status === 401 && err?.code !== "EAUTH")
|
||||||
|
) {
|
||||||
|
setState({ mode: "local" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No answer at all (a restart, a proxy's 502): ask again rather than
|
||||||
|
// guess — "local" would hide a server-mode dashboard's login page.
|
||||||
|
timer = window.setTimeout(ask, RETRY_MS);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
ask();
|
||||||
|
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||||
|
const onRequired = () => setState({ mode: "server", user: null });
|
||||||
|
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
if (state.mode === "server" && !state.user) {
|
||||||
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||||
|
}
|
||||||
|
const logout = async () => {
|
||||||
|
await api.auth.logout().catch(() => {});
|
||||||
|
setState({ mode: "server", user: null });
|
||||||
|
};
|
||||||
|
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onLoggedIn((await api.auth.login(username, password)).user);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
|
||||||
|
setPassword("");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||||
|
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
||||||
|
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||||
|
<span>{t("auth.username")}</span>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||||
|
<span>{t("auth.password")}</span>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||||
|
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={message ? messageId : undefined}
|
aria-describedby={message ? messageId : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-5">
|
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* @file EventReplay.tsx
|
||||||
|
* @description The session Replay tab: a scrubber over a session's recorded
|
||||||
|
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
|
||||||
|
* whose markers jump the cursor, the state derived up to the cursor
|
||||||
|
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
|
||||||
|
* events, so the live analysis page and a frozen snapshot share it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
chronological,
|
||||||
|
lifecycleMarkers,
|
||||||
|
replayState,
|
||||||
|
LIFECYCLE_TYPES,
|
||||||
|
REPLAY_SPEEDS,
|
||||||
|
REPLAY_STEP_MS,
|
||||||
|
} from "../lib/replay";
|
||||||
|
import { buildEventTitle } from "../lib/event-grouping";
|
||||||
|
import type { AgentInfo } from "../lib/event-grouping";
|
||||||
|
import { formatDateTimeFull, formatMs } from "../lib/format";
|
||||||
|
import type { DashboardEvent } from "../lib/types";
|
||||||
|
import { AgentStatusBadge } from "./StatusBadge";
|
||||||
|
import { EventDetail } from "./EventDetail";
|
||||||
|
|
||||||
|
/** The newest this many events are replayed; older ones are left out. */
|
||||||
|
export const REPLAY_MAX_EVENTS = 5000;
|
||||||
|
const PAGE = 500;
|
||||||
|
|
||||||
|
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
|
||||||
|
export async function loadReplayEvents(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ events: DashboardEvent[]; total: number }> {
|
||||||
|
const all: DashboardEvent[] = [];
|
||||||
|
let total = 0;
|
||||||
|
do {
|
||||||
|
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
|
||||||
|
total = r.total;
|
||||||
|
if (r.events.length === 0) break;
|
||||||
|
all.push(...r.events);
|
||||||
|
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
|
||||||
|
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_COLOR: Record<string, string> = {
|
||||||
|
SessionStart: "bg-status-success",
|
||||||
|
UserPromptSubmit: "bg-accent",
|
||||||
|
Compaction: "bg-status-warning",
|
||||||
|
SubagentStop: "bg-violet-500",
|
||||||
|
APIError: "bg-status-danger",
|
||||||
|
Stop: "bg-fg-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
export function EventReplay({
|
||||||
|
events: raw,
|
||||||
|
total,
|
||||||
|
agentInfoById,
|
||||||
|
sessionNameById,
|
||||||
|
}: {
|
||||||
|
events: DashboardEvent[];
|
||||||
|
/** The session's full event count, when more than `events` were recorded. */
|
||||||
|
total?: number;
|
||||||
|
agentInfoById: Map<string, AgentInfo>;
|
||||||
|
sessionNameById: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const events = useMemo(() => chronological(raw), [raw]);
|
||||||
|
const markers = useMemo(() => lifecycleMarkers(events), [events]);
|
||||||
|
const last = events.length - 1;
|
||||||
|
const [cursor, setCursor] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState<number>(1);
|
||||||
|
const at = Math.min(cursor, Math.max(0, last));
|
||||||
|
const state = useMemo(() => replayState(events, at), [events, at]);
|
||||||
|
const event = events[at];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
if (at >= last) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [playing, at, last, speed]);
|
||||||
|
|
||||||
|
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
|
||||||
|
|
||||||
|
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
|
||||||
|
const toggle = () => {
|
||||||
|
if (!playing && at >= last) setCursor(0);
|
||||||
|
setPlaying((p) => !p);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// The slider steps itself on arrow keys; only the rest is ours there.
|
||||||
|
const onSlider = e.target instanceof HTMLInputElement;
|
||||||
|
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
} else if (onSlider) return;
|
||||||
|
else if (e.key === "ArrowLeft") go(at - 1);
|
||||||
|
else if (e.key === "ArrowRight") go(at + 1);
|
||||||
|
else if (e.key === "Home") go(0);
|
||||||
|
else if (e.key === "End") go(last);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
const markerLabel = (type: string) => t(`replay.marker.${type}`);
|
||||||
|
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-4 outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
role="group"
|
||||||
|
aria-label={t("replay.label")}
|
||||||
|
data-testid="event-replay"
|
||||||
|
>
|
||||||
|
{total != null && total > events.length && (
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t("replay.truncated", { shown: events.length, total })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card space-y-3 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
onClick={() => go(at - 1)}
|
||||||
|
disabled={at <= 0}
|
||||||
|
title={t("replay.back")}
|
||||||
|
>
|
||||||
|
<StepBack className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${btn} bg-accent/15 text-accent`}
|
||||||
|
onClick={toggle}
|
||||||
|
title={playing ? t("replay.pause") : t("replay.play")}
|
||||||
|
aria-pressed={playing}
|
||||||
|
>
|
||||||
|
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={btn}
|
||||||
|
onClick={() => go(at + 1)}
|
||||||
|
disabled={at >= last}
|
||||||
|
title={t("replay.forward")}
|
||||||
|
>
|
||||||
|
<StepForward className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="ml-2 flex items-center gap-0.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("replay.speed")}
|
||||||
|
>
|
||||||
|
{REPLAY_SPEEDS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSpeed(s)}
|
||||||
|
aria-pressed={speed === s}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-mono ${
|
||||||
|
speed === s
|
||||||
|
? "bg-accent/15 text-accent"
|
||||||
|
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto font-mono text-xs text-fg-muted">
|
||||||
|
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
|
||||||
|
{formatDateTimeFull(event.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={last}
|
||||||
|
value={at}
|
||||||
|
onChange={(e) => go(Number(e.target.value))}
|
||||||
|
aria-label={t("replay.scrubber")}
|
||||||
|
className="w-full accent-[rgb(var(--accent))]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative h-4 rounded bg-surface-2"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("replay.minimap")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
|
||||||
|
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
{markers.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(m.index)}
|
||||||
|
title={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||||
|
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||||
|
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
|
||||||
|
style={{ left: `${m.pct}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{present.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
|
||||||
|
{present.map((k) => (
|
||||||
|
<span key={k} className="inline-flex items-center gap-1">
|
||||||
|
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
|
||||||
|
{markerLabel(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||||
|
<Tile label={t("replay.state.status")}>
|
||||||
|
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
|
||||||
|
</Tile>
|
||||||
|
<Tile label={t("replay.state.tool")}>
|
||||||
|
<span className="font-mono">{state.currentTool ?? "-"}</span>
|
||||||
|
</Tile>
|
||||||
|
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
|
||||||
|
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
|
||||||
|
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
|
||||||
|
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
|
||||||
|
</div>
|
||||||
|
{state.toolCounts.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("replay.state.toolCounts")}
|
||||||
|
>
|
||||||
|
{state.toolCounts.map((x) => (
|
||||||
|
<span
|
||||||
|
key={x.tool}
|
||||||
|
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
{x.tool} ×{x.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
|
||||||
|
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
|
||||||
|
{buildEventTitle(event)}
|
||||||
|
</div>
|
||||||
|
<EventDetail
|
||||||
|
event={event}
|
||||||
|
agentInfoById={agentInfoById}
|
||||||
|
sessionNameById={sessionNameById}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @file EventTimeline.tsx
|
||||||
|
* @description A session's events as a flat, expandable list — one row per
|
||||||
|
* event (age, status badge, origin · title, tool), opening into EventDetail.
|
||||||
|
* Used by the session analysis page's Timeline tab and the read-only snapshot
|
||||||
|
* page.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AgentStatusBadge } from "./StatusBadge";
|
||||||
|
import { EventDetail } from "./EventDetail";
|
||||||
|
import {
|
||||||
|
agentOriginLabel,
|
||||||
|
buildEventTitle,
|
||||||
|
buildOriginLabel,
|
||||||
|
projectFromCwd,
|
||||||
|
projectFromEvent,
|
||||||
|
statusFromEventType,
|
||||||
|
} from "../lib/event-grouping";
|
||||||
|
import type { AgentInfo } from "../lib/event-grouping";
|
||||||
|
import { timeAgo } from "../lib/format";
|
||||||
|
import type { DashboardEvent } from "../lib/types";
|
||||||
|
|
||||||
|
export function EventTimeline({
|
||||||
|
events,
|
||||||
|
agentInfoById,
|
||||||
|
sessionNameById,
|
||||||
|
fallbackCwd,
|
||||||
|
}: {
|
||||||
|
events: DashboardEvent[];
|
||||||
|
agentInfoById: Map<string, AgentInfo>;
|
||||||
|
sessionNameById: Map<string, string>;
|
||||||
|
/** The session's cwd, for events that carry none of their own. */
|
||||||
|
fallbackCwd?: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||||
|
|
||||||
|
function toggleEvent(id: number) {
|
||||||
|
setExpandedEvents((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||||
|
const projectByEventId = useMemo(() => {
|
||||||
|
const map = new Map<number, string | null>();
|
||||||
|
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||||
|
{events.map((event, i) => {
|
||||||
|
const key = event.id ?? i;
|
||||||
|
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||||
|
return (
|
||||||
|
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={
|
||||||
|
isOpen
|
||||||
|
? undefined
|
||||||
|
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={
|
||||||
|
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||||
|
}
|
||||||
|
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||||
|
{timeAgo(event.created_at)}
|
||||||
|
</div>
|
||||||
|
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||||
|
{(() => {
|
||||||
|
// Session is implicit on this page - project is
|
||||||
|
// still shown so the row identifies the working
|
||||||
|
// directory when you share / search. Events without
|
||||||
|
// their own cwd (e.g. TurnDuration) fall back to the
|
||||||
|
// session's cwd so every row carries the dir prefix.
|
||||||
|
const project =
|
||||||
|
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
|
||||||
|
const origin = buildOriginLabel(
|
||||||
|
project,
|
||||||
|
null,
|
||||||
|
agentOriginLabel(event.agent_id, agentInfoById)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||||
|
{origin && (
|
||||||
|
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
|
||||||
|
{origin} ·
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{buildEventTitle(event)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{event.tool_name && (
|
||||||
|
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||||
|
{event.tool_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<EventDetail
|
||||||
|
event={event}
|
||||||
|
agentInfoById={agentInfoById}
|
||||||
|
sessionNameById={sessionNameById}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
* written back on every toggle. Failures to access storage are swallowed so a
|
* written back on every toggle. Failures to access storage are swallowed so a
|
||||||
* private-browsing quota error never breaks the UI.
|
* private-browsing quota error never breaks the UI.
|
||||||
*
|
*
|
||||||
|
* ## Sidebar width
|
||||||
|
* The expanded sidebar's right edge drags (or takes ←/→) between
|
||||||
|
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
|
||||||
|
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
|
||||||
|
*
|
||||||
* ## Narrow viewports
|
* ## Narrow viewports
|
||||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
* - `./Sagi/Sagi`
|
* - `./Sagi/SagiMount`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -76,9 +81,29 @@ import { Menu } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||||
import { UpdateNotifier } from "./UpdateNotifier";
|
import { UpdateNotifier } from "./UpdateNotifier";
|
||||||
import { Sagi } from "./Sagi/Sagi";
|
import { SagiMount } from "./Sagi/SagiMount";
|
||||||
import { StatusBar } from "./StatusBar";
|
import { StatusBar } from "./StatusBar";
|
||||||
|
|
||||||
|
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||||
|
/** The default width, and the narrowest the edge drags to. */
|
||||||
|
export const SIDEBAR_MIN = 240;
|
||||||
|
/** Wide enough for long workspace names on a large screen, no wider. */
|
||||||
|
export const SIDEBAR_MAX = 480;
|
||||||
|
|
||||||
|
/** A width inside the bounds, and at most 40% of the window. */
|
||||||
|
export function clampSidebar(px: number): number {
|
||||||
|
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
|
||||||
|
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWidth(): number {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(WIDTH_KEY));
|
||||||
|
if (n) return clampSidebar(n);
|
||||||
|
} catch {}
|
||||||
|
return SIDEBAR_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||||
|
|
||||||
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
const [width, setWidth] = useState(loadWidth);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
|
||||||
|
|
||||||
|
const saveWidth = useCallback((px: number) => {
|
||||||
|
const next = clampSidebar(px);
|
||||||
|
setWidth(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(WIDTH_KEY, String(next));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// A pick in the drawer navigates; the drawer has done its job.
|
// A pick in the drawer navigates; the drawer has done its job.
|
||||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||||
@@ -136,7 +171,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
{t("skipToContent")}
|
{t("skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
<UpdateNotifier />
|
<UpdateNotifier />
|
||||||
<Sagi />
|
<SagiMount />
|
||||||
{drawer && drawerOpen && (
|
{drawer && drawerOpen && (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
connectionOpen={connectionOpen}
|
connectionOpen={connectionOpen}
|
||||||
onConnectionOpenChange={setConnectionOpen}
|
onConnectionOpenChange={setConnectionOpen}
|
||||||
onUpdateAvailableChange={setUpdateAvailable}
|
onUpdateAvailableChange={setUpdateAvailable}
|
||||||
|
width={width}
|
||||||
|
resizing={resizing}
|
||||||
/>
|
/>
|
||||||
|
{!drawer && !collapsed && (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuenow={width}
|
||||||
|
aria-valuemin={SIDEBAR_MIN}
|
||||||
|
aria-valuemax={SIDEBAR_MAX}
|
||||||
|
aria-label={t("resizeSidebar")}
|
||||||
|
title={t("resizeSidebar")}
|
||||||
|
data-testid="sidebar-resize"
|
||||||
|
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
|
||||||
|
resizing ? "bg-accent/60" : ""
|
||||||
|
}`}
|
||||||
|
style={{ left: width }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
setResizing(true);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (resizing) setWidth(clampSidebar(e.clientX));
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (!resizing) return;
|
||||||
|
setResizing(false);
|
||||||
|
saveWidth(e.clientX);
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => setResizing(false)}
|
||||||
|
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||||
|
e.preventDefault();
|
||||||
|
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
|
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
|
||||||
|
resizing ? "" : "transition-[margin-left,width]"
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: sidebarWidth,
|
marginLeft: sidebarWidth,
|
||||||
width: `calc(100% - ${sidebarWidth})`,
|
width: `calc(100% - ${sidebarWidth})`,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
|
|||||||
import { api, type RunHandle } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import { timeAgo } from "../lib/format";
|
import { timeAgo } from "../lib/format";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||||
import { workspaceHref } from "./SessionRowMenu";
|
import { workspaceHref } from "./SessionRowMenu";
|
||||||
import type { Session } from "../lib/types";
|
import type { Session } from "../lib/types";
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
setTotal(r.total);
|
setTotal(r.total);
|
||||||
})
|
})
|
||||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||||
|
}, []);
|
||||||
|
// The live panes (for the links) move only when a lane's run columns do.
|
||||||
|
const loadRuns = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
.then((r) => setRuns(r.items))
|
.then((r) => setRuns(r.items))
|
||||||
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
loadRuns();
|
||||||
|
}, [load, loadRuns]);
|
||||||
useLiveRefresh(load, {
|
useLiveRefresh(load, {
|
||||||
intervalMs: 15000,
|
intervalMs: 15000,
|
||||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||||
});
|
});
|
||||||
|
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||||
|
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||||
|
|
||||||
if (sessions.length === 0) return null;
|
if (sessions.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file RemoteSources.tsx
|
* @file RemoteSources.tsx
|
||||||
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
||||||
* machines this dashboard pulls Claude Code history from, and choose the global
|
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
|
||||||
|
* history from, and choose the global
|
||||||
* "data scope" (which machines' data the whole app shows).
|
* "data scope" (which machines' data the whole app shows).
|
||||||
*
|
*
|
||||||
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
||||||
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
|
|||||||
ssh_port: null,
|
ssh_port: null,
|
||||||
identity_file: "",
|
identity_file: "",
|
||||||
remote_home: "",
|
remote_home: "",
|
||||||
|
sync_codex: false,
|
||||||
|
remote_codex_home: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,6 +222,8 @@ export function RemoteSources() {
|
|||||||
ssh_port: s.ssh_port,
|
ssh_port: s.ssh_port,
|
||||||
identity_file: s.identity_file || "",
|
identity_file: s.identity_file || "",
|
||||||
remote_home: s.remote_home || "",
|
remote_home: s.remote_home || "",
|
||||||
|
sync_codex: !!s.sync_codex,
|
||||||
|
remote_codex_home: s.remote_codex_home || "",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
});
|
});
|
||||||
setEditingId(s.id);
|
setEditingId(s.id);
|
||||||
@@ -242,6 +247,8 @@ export function RemoteSources() {
|
|||||||
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
||||||
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
||||||
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
||||||
|
sync_codex: !!form.sync_codex,
|
||||||
|
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -591,6 +598,31 @@ export function RemoteSources() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={!!form.sync_codex}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
|
||||||
|
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
|
||||||
|
labelClassName="text-sm text-fg-secondary"
|
||||||
|
/>
|
||||||
|
{form.sync_codex && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-fg-muted mb-1">
|
||||||
|
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input w-full font-mono"
|
||||||
|
placeholder="~/.codex"
|
||||||
|
value={form.remote_codex_home ?? ""}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
||||||
|
{t(
|
||||||
|
"remoteSources.fieldRemoteCodexHomeHint",
|
||||||
|
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!!form.enabled}
|
checked={!!form.enabled}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||||
@@ -674,6 +706,9 @@ export function RemoteSources() {
|
|||||||
{s.host}
|
{s.host}
|
||||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||||
|
{s.sync_codex
|
||||||
|
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-fg-muted mt-1">
|
<div className="text-[11px] text-fg-muted mt-1">
|
||||||
{s.last_sync_at
|
{s.last_sync_at
|
||||||
@@ -689,9 +724,19 @@ export function RemoteSources() {
|
|||||||
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
||||||
n: s.last_sync_counts.sessions_tagged,
|
n: s.last_sync_counts.sessions_tagged,
|
||||||
})}`}
|
})}`}
|
||||||
|
{s.last_sync_counts?.codex_sessions != null &&
|
||||||
|
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
|
||||||
|
n: s.last_sync_counts.codex_sessions,
|
||||||
|
})}`}
|
||||||
</div>
|
</div>
|
||||||
{s.status === "error" && s.last_error && (
|
{/* A sync that pulled one CLI's history but not the other's is
|
||||||
<div className="text-[11px] text-status-danger mt-1 break-words">
|
`ok` with the other's failure in last_error. */}
|
||||||
|
{s.last_error && (s.status === "error" || s.status === "ok") && (
|
||||||
|
<div
|
||||||
|
className={`text-[11px] mt-1 break-words ${
|
||||||
|
s.status === "error" ? "text-status-danger" : "text-status-warning"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{s.last_error}
|
{s.last_error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||||
* with Settings → Pet.
|
* with Settings → Appearance.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||||
|
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||||
|
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||||
|
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||||
* back to Sagi instead of an empty box.
|
* back to Sagi instead of an empty box.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
|
|||||||
worried: 5, // failed
|
worried: 5, // failed
|
||||||
disconnected: 5,
|
disconnected: 5,
|
||||||
};
|
};
|
||||||
|
/** What the user is doing to the pet, which outranks its mood. */
|
||||||
|
export type PetAction = "run-left" | "run-right" | "wave";
|
||||||
|
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||||
|
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||||
|
const PACE: [number, number][] = [
|
||||||
|
[220, 1400], // idle: a breath, then a pause
|
||||||
|
[120, 120], // run right — keeps up with the drag
|
||||||
|
[120, 120], // run left
|
||||||
|
[180, 400], // waving
|
||||||
|
[160, 700], // jumping
|
||||||
|
[200, 1200], // failed
|
||||||
|
[220, 900], // waiting
|
||||||
|
[170, 400], // running (working)
|
||||||
|
[220, 900], // review
|
||||||
|
];
|
||||||
|
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||||
|
|
||||||
export function PetSprite({
|
export function PetSprite({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
mood,
|
mood,
|
||||||
|
action,
|
||||||
size,
|
size,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
onBroken,
|
onBroken,
|
||||||
@@ -42,23 +62,36 @@ export function PetSprite({
|
|||||||
id: string;
|
id: string;
|
||||||
kind: "codex" | "image";
|
kind: "codex" | "image";
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
|
action?: PetAction | null;
|
||||||
size: number;
|
size: number;
|
||||||
reducedMotion: boolean;
|
reducedMotion: boolean;
|
||||||
onBroken: () => void;
|
onBroken: () => void;
|
||||||
}) {
|
}) {
|
||||||
const url = api.pets.spriteUrl(id);
|
const url = api.pets.spriteUrl(id);
|
||||||
const row = ROW[mood] ?? 0;
|
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||||
|
const sleeping = !action && mood === "sleeping";
|
||||||
const [frame, setFrame] = useState(0);
|
const [frame, setFrame] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFrame(0);
|
setFrame(0);
|
||||||
if (kind !== "codex" || reducedMotion) return;
|
if (kind !== "codex" || reducedMotion) return;
|
||||||
const timer = window.setInterval(
|
const count = FRAMES[row] ?? 1;
|
||||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||||
mood === "sleeping" ? 400 : 125
|
let f = 0;
|
||||||
|
let timer = 0;
|
||||||
|
const tick = () => {
|
||||||
|
timer = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
f = (f + 1) % count;
|
||||||
|
setFrame(f);
|
||||||
|
tick();
|
||||||
|
},
|
||||||
|
f === count - 1 ? hold : step
|
||||||
);
|
);
|
||||||
return () => window.clearInterval(timer);
|
};
|
||||||
}, [kind, row, mood, reducedMotion]);
|
tick();
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [kind, row, sleeping, reducedMotion]);
|
||||||
|
|
||||||
// A background image reports no error of its own: probe it once.
|
// A background image reports no error of its own: probe it once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.tsx
|
* @file Sagi.tsx
|
||||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||||
|
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||||
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
|
* shares the single WebSocket.
|
||||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
@@ -81,6 +84,7 @@ import { SpeechBubble } from "./SpeechBubble";
|
|||||||
import { SagiPanel } from "./SagiPanel";
|
import { SagiPanel } from "./SagiPanel";
|
||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
@@ -195,24 +199,29 @@ function SagiFlyout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
|
||||||
export function Sagi() {
|
export function Sagi() {
|
||||||
|
return useSagiEnabled() ? <SagiBody /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SagiBody() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
|
||||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// The right-click pet menu (`PetMenu.tsx`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
const reducedMotion = usePrefersReducedMotion();
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const brain = useSagiBrain();
|
const brain = useSagiBrain();
|
||||||
const place = useSagiPosition(size);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||||
|
// the `Sagi` gate's: it unmounts this body when the mascot is turned off).
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
sagiPrefs.subscribe(() => {
|
sagiPrefs.subscribe(() => {
|
||||||
setEnabled(sagiPrefs.getEnabled());
|
|
||||||
setPet(sagiPrefs.getPet());
|
setPet(sagiPrefs.getPet());
|
||||||
setSize(sagiPrefs.getSize());
|
setSize(sagiPrefs.getSize());
|
||||||
}),
|
}),
|
||||||
@@ -222,7 +231,6 @@ export function Sagi() {
|
|||||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||||
@@ -234,7 +242,7 @@ export function Sagi() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [enabled]);
|
}, []);
|
||||||
|
|
||||||
const onNavigate = useCallback(
|
const onNavigate = useCallback(
|
||||||
(route: string) => {
|
(route: string) => {
|
||||||
@@ -278,8 +286,6 @@ export function Sagi() {
|
|||||||
[brain, navigate, t]
|
[brain, navigate, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!enabled) return null;
|
|
||||||
|
|
||||||
const anchor: Anchor = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -315,7 +321,7 @@ export function Sagi() {
|
|||||||
onClose={() => setMenu(false)}
|
onClose={() => setMenu(false)}
|
||||||
onSettings={() => {
|
onSettings={() => {
|
||||||
setMenu(false);
|
setMenu(false);
|
||||||
navigate("/settings/pet");
|
navigate("/settings/appearance#pet");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
@@ -331,6 +337,8 @@ export function Sagi() {
|
|||||||
className="sagi-avatar-btn"
|
className="sagi-avatar-btn"
|
||||||
data-dragging={place.dragging ? "1" : "0"}
|
data-dragging={place.dragging ? "1" : "0"}
|
||||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={() => setHover(false)}
|
||||||
onPointerDown={place.onPointerDown}
|
onPointerDown={place.onPointerDown}
|
||||||
onPointerMove={place.onPointerMove}
|
onPointerMove={place.onPointerMove}
|
||||||
onPointerUp={place.onPointerUp}
|
onPointerUp={place.onPointerUp}
|
||||||
@@ -358,6 +366,8 @@ export function Sagi() {
|
|||||||
id={pet.id}
|
id={pet.id}
|
||||||
kind={pet.kind}
|
kind={pet.kind}
|
||||||
mood={brain.mood}
|
mood={brain.mood}
|
||||||
|
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||||
|
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||||
size={size}
|
size={size}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood } from "./brain";
|
||||||
|
|
||||||
interface SagiAvatarProps {
|
interface SagiAvatarProps {
|
||||||
@@ -76,10 +76,21 @@ if (typeof window !== "undefined") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
|
// The pupil offset is written as CSS variables on the root, never as state, so
|
||||||
|
// following the cursor costs no React render.
|
||||||
|
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
|
||||||
|
el?.style.setProperty("--sagi-px", `${x}px`);
|
||||||
|
el?.style.setProperty("--sagi-py", `${y}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
|
||||||
|
export const SagiAvatar = memo(function SagiAvatar({
|
||||||
|
mood,
|
||||||
|
reducedMotion,
|
||||||
|
size = 68,
|
||||||
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number>();
|
||||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||||
@@ -89,7 +100,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracking) {
|
if (!tracking) {
|
||||||
setPupil({ x: 0, y: 0 });
|
setPupil(rootRef.current, 0, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +116,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
// Normalize then clamp to the eye socket range.
|
// Normalize then clamp to the eye socket range.
|
||||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||||
@@ -202,7 +213,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
<g className="sagi-eye sagi-eye-left">
|
<g className="sagi-eye sagi-eye-left">
|
||||||
<g
|
<g
|
||||||
className="sagi-eye-track"
|
className="sagi-eye-track"
|
||||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||||
>
|
>
|
||||||
<g className="sagi-eye-blink">
|
<g className="sagi-eye-blink">
|
||||||
<path
|
<path
|
||||||
@@ -226,7 +237,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
<g className="sagi-eye sagi-eye-right">
|
<g className="sagi-eye sagi-eye-right">
|
||||||
<g
|
<g
|
||||||
className="sagi-eye-track"
|
className="sagi-eye-track"
|
||||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||||
>
|
>
|
||||||
<g className="sagi-eye-blink">
|
<g className="sagi-eye-blink">
|
||||||
<path
|
<path
|
||||||
@@ -792,4 +803,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file SagiMount.tsx
|
||||||
|
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||||
|
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||||
|
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||||
|
* WebSocket reducer.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { sagiPrefs } from "./prefs";
|
||||||
|
|
||||||
|
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||||
|
|
||||||
|
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||||
|
export function useSagiEnabled(): boolean {
|
||||||
|
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||||
|
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SagiMount() {
|
||||||
|
if (!useSagiEnabled()) return null;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Sagi />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
|
|||||||
expect(sprite.dataset.row).toBe("7");
|
expect(sprite.dataset.row).toBe("7");
|
||||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||||
act(() => vi.advanceTimersByTime(130));
|
act(() => vi.advanceTimersByTime(130));
|
||||||
|
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||||
|
act(() => vi.advanceTimersByTime(50));
|
||||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||||
rerender(
|
rerender(
|
||||||
<PetSprite
|
<PetSprite
|
||||||
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
|
|||||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs the way it is dragged and waves on hover, over its mood", () => {
|
||||||
|
const props = {
|
||||||
|
id: "cp-goku",
|
||||||
|
kind: "codex" as const,
|
||||||
|
mood: "thinking" as const,
|
||||||
|
size: 208,
|
||||||
|
reducedMotion: false,
|
||||||
|
onBroken: () => {},
|
||||||
|
};
|
||||||
|
const { rerender } = render(<PetSprite {...props} action="run-left" />);
|
||||||
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
|
||||||
|
rerender(<PetSprite {...props} action="run-right" />);
|
||||||
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
|
||||||
|
rerender(<PetSprite {...props} action="wave" />);
|
||||||
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
|
||||||
|
rerender(<PetSprite {...props} action={null} />);
|
||||||
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds a loop's last frame before starting over", () => {
|
||||||
|
render(
|
||||||
|
<PetSprite
|
||||||
|
id="cp-goku"
|
||||||
|
kind="codex"
|
||||||
|
mood="idle"
|
||||||
|
size={208}
|
||||||
|
reducedMotion={false}
|
||||||
|
onBroken={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const sprite = screen.getByTestId("pet-sprite");
|
||||||
|
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||||
|
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||||
|
act(() => vi.advanceTimersByTime(1000));
|
||||||
|
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||||
|
act(() => vi.advanceTimersByTime(400));
|
||||||
|
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||||
|
});
|
||||||
|
|
||||||
it("holds the first frame under reduced motion", () => {
|
it("holds the first frame under reduced motion", () => {
|
||||||
render(
|
render(
|
||||||
<PetSprite
|
<PetSprite
|
||||||
@@ -159,7 +200,7 @@ describe("PetMenu", () => {
|
|||||||
fireEvent.click(screen.getByText("Hide pet"));
|
fireEvent.click(screen.getByText("Hide pet"));
|
||||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||||
// Says where to bring it back.
|
// Says where to bring it back.
|
||||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
|
||||||
off();
|
off();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
renderHook,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { useSagiPosition } from "../useSagiPosition";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Sagi } from "../Sagi";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
|
|||||||
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useSagiPosition moving", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("names the drag's direction and settles once the pointer rests", () => {
|
||||||
|
const { result } = renderHook(() => useSagiPosition(68));
|
||||||
|
const target = { setPointerCapture() {}, releasePointerCapture() {} };
|
||||||
|
const ev = (x: number) =>
|
||||||
|
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
|
||||||
|
act(() => result.current.onPointerDown(ev(500)));
|
||||||
|
act(() => result.current.onPointerMove(ev(480)));
|
||||||
|
expect(result.current.moving).toBe("left");
|
||||||
|
act(() => result.current.onPointerMove(ev(520)));
|
||||||
|
expect(result.current.moving).toBe("right");
|
||||||
|
act(() => vi.advanceTimersByTime(400));
|
||||||
|
expect(result.current.moving).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
|
||||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||||
|
|||||||
@@ -168,7 +168,10 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||||
// happy/worried) re-evaluate without needing a new event.
|
// happy/worried) re-evaluate without needing a new event.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") setTick(Date.now());
|
||||||
|
}, 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||||
|
* `moving` names the way it is heading — while dragged (cleared once the
|
||||||
|
* pointer rests) and during the glide to its edge — so a pet can run that way.
|
||||||
* It never rests over the fixed bottom status bar; by default it docks
|
* It never rests over the fixed bottom status bar; by default it docks
|
||||||
* bottom-right just above it.
|
* bottom-right just above it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
|
|||||||
const STATUS_BAR = 24;
|
const STATUS_BAR = 24;
|
||||||
// One arrow-key step, in px.
|
// One arrow-key step, in px.
|
||||||
const KEY_STEP = 40;
|
const KEY_STEP = 40;
|
||||||
|
// A held but unmoving drag stops running after this; also the snap glide's length.
|
||||||
|
const MOVE_SETTLE_MS = 300;
|
||||||
|
|
||||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||||
@@ -109,6 +113,8 @@ export interface SagiPlacement {
|
|||||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||||
openUp: boolean;
|
openUp: boolean;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
|
/** The way the avatar is moving horizontally right now, if it is. */
|
||||||
|
moving: "left" | "right" | null;
|
||||||
onPointerDown: (e: ReactPointerEvent) => void;
|
onPointerDown: (e: ReactPointerEvent) => void;
|
||||||
onPointerMove: (e: ReactPointerEvent) => void;
|
onPointerMove: (e: ReactPointerEvent) => void;
|
||||||
onPointerUp: (e: ReactPointerEvent) => void;
|
onPointerUp: (e: ReactPointerEvent) => void;
|
||||||
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [, force] = useState(0); // re-derive resting coords on resize
|
const [, force] = useState(0); // re-derive resting coords on resize
|
||||||
|
const [moving, setMoving] = useState<"left" | "right" | null>(null);
|
||||||
|
const moveTimer = useRef(0);
|
||||||
|
const lastX = useRef(0);
|
||||||
|
const heading = useCallback((dir: "left" | "right" | null) => {
|
||||||
|
window.clearTimeout(moveTimer.current);
|
||||||
|
setMoving(dir);
|
||||||
|
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
|
||||||
|
|
||||||
const draggedRef = useRef(false);
|
const draggedRef = useRef(false);
|
||||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||||
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
/* capture unsupported - window-free fallback still works via props */
|
/* capture unsupported - window-free fallback still works via props */
|
||||||
}
|
}
|
||||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||||
|
lastX.current = e.clientX;
|
||||||
movedRef.current = false;
|
movedRef.current = false;
|
||||||
},
|
},
|
||||||
[screen.left, screen.top]
|
[screen.left, screen.top]
|
||||||
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
);
|
);
|
||||||
liveRef.current = { left, top };
|
liveRef.current = { left, top };
|
||||||
setDrag({ left, top });
|
setDrag({ left, top });
|
||||||
|
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
|
||||||
|
const stepX = e.clientX - lastX.current;
|
||||||
|
if (Math.abs(stepX) >= 2) {
|
||||||
|
lastX.current = e.clientX;
|
||||||
|
heading(stepX > 0 ? "right" : "left");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[size]
|
[size, heading]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerUp = useCallback(
|
const onPointerUp = useCallback(
|
||||||
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
sagiPrefs.setPos(next);
|
sagiPrefs.setPos(next);
|
||||||
setPos(next);
|
setPos(next);
|
||||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||||
|
// Run along the glide to the edge, when there is a real distance to cover.
|
||||||
|
const glide = restingScreen(next, size).left - live.left;
|
||||||
|
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
|
||||||
}
|
}
|
||||||
liveRef.current = null;
|
liveRef.current = null;
|
||||||
startRef.current = null;
|
startRef.current = null;
|
||||||
movedRef.current = false;
|
movedRef.current = false;
|
||||||
},
|
},
|
||||||
[size]
|
[size, heading]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
}
|
}
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (next.side !== pos.side) heading(next.side);
|
||||||
sagiPrefs.setPos(next);
|
sagiPrefs.setPos(next);
|
||||||
setPos(next);
|
setPos(next);
|
||||||
},
|
},
|
||||||
[pos, size]
|
[pos, size, heading]
|
||||||
);
|
);
|
||||||
|
|
||||||
const consumeDrag = useCallback(() => {
|
const consumeDrag = useCallback(() => {
|
||||||
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|||||||
side: pos.side,
|
side: pos.side,
|
||||||
openUp: screen.top + size / 2 > vh() / 2,
|
openUp: screen.top + size / 2 > vh() / 2,
|
||||||
dragging: drag !== null,
|
dragging: drag !== null,
|
||||||
|
moving,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
|
|||||||
@@ -76,22 +76,27 @@ import type { Agent, Session, SessionStats } from "../lib/types";
|
|||||||
interface SessionOverviewProps {
|
interface SessionOverviewProps {
|
||||||
session: Session;
|
session: Session;
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
|
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
|
||||||
|
stats?: SessionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||||
const REFRESH_THROTTLE_MS = 1000;
|
const REFRESH_THROTTLE_MS = 5000;
|
||||||
|
|
||||||
/** Compact tile used in the top stat row. */
|
/** Compact tile used in the top stat row. */
|
||||||
function StatTile({
|
function StatTile({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
|
help,
|
||||||
icon,
|
icon,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** Plain-language explanation, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||||
}) {
|
}) {
|
||||||
@@ -118,9 +123,14 @@ function StatTile({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
<div
|
||||||
|
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
||||||
|
title={help}
|
||||||
|
>
|
||||||
<span className={iconTone}>{icon}</span>
|
<span className={iconTone}>{icon}</span>
|
||||||
|
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||||
{label}
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||||
{value}
|
{value}
|
||||||
@@ -160,18 +170,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
const [fetched, setStats] = useState<SessionStats | null>(null);
|
||||||
|
const stats = frozen ?? fetched;
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.status !== "active") return;
|
if (frozen || session.status !== "active") return;
|
||||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [session.status]);
|
}, [frozen, session.status]);
|
||||||
|
|
||||||
// A refresh asked for while one is in flight is not dropped: it runs once
|
// A refresh asked for while one is in flight is not dropped: it runs once
|
||||||
// when the current one settles (that is the throttle's trailing call).
|
// when the current one settles (that is the throttle's trailing call).
|
||||||
@@ -198,6 +209,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
|
|
||||||
// Initial load + reload when the session id changes.
|
// Initial load + reload when the session id changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (frozen) return;
|
||||||
fetchStats();
|
fetchStats();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
@@ -206,6 +218,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
// stream still refreshes) and again after a reconnect.
|
// stream still refreshes) and again after a reconnect.
|
||||||
useLiveRefresh(() => void fetchStats(), {
|
useLiveRefresh(() => void fetchStats(), {
|
||||||
throttleMs: REFRESH_THROTTLE_MS,
|
throttleMs: REFRESH_THROTTLE_MS,
|
||||||
|
enabled: !frozen,
|
||||||
match: (msg) => {
|
match: (msg) => {
|
||||||
if (isRemoteDataRefreshMessage(msg)) return true;
|
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||||
if (
|
if (
|
||||||
@@ -305,6 +318,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.events")}
|
label={t("overview.events")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
value={stats.total_events.toLocaleString()}
|
value={stats.total_events.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
eventRate !== null && eventRate > 0
|
eventRate !== null && eventRate > 0
|
||||||
@@ -317,6 +331,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.toolCalls")}
|
label={t("overview.toolCalls")}
|
||||||
|
help={t("common:insights.help.toolCalls")}
|
||||||
value={toolCallCount.toLocaleString()}
|
value={toolCallCount.toLocaleString()}
|
||||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||||
icon={<Wrench className="w-3 h-3" />}
|
icon={<Wrench className="w-3 h-3" />}
|
||||||
@@ -324,6 +339,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.subagents")}
|
label={t("overview.subagents")}
|
||||||
|
help={t("common:insights.help.subagents")}
|
||||||
value={stats.agents.subagent.toLocaleString()}
|
value={stats.agents.subagent.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||||
@@ -333,12 +349,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.compactions")}
|
label={t("overview.compactions")}
|
||||||
|
help={t("common:insights.help.compactions")}
|
||||||
value={stats.agents.compaction.toLocaleString()}
|
value={stats.agents.compaction.toLocaleString()}
|
||||||
icon={<Layers className="w-3 h-3" />}
|
icon={<Layers className="w-3 h-3" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.errors")}
|
label={t("overview.errors")}
|
||||||
|
help={t("common:insights.help.errors")}
|
||||||
value={stats.error_count.toLocaleString()}
|
value={stats.error_count.toLocaleString()}
|
||||||
icon={<AlertTriangle className="w-3 h-3" />}
|
icon={<AlertTriangle className="w-3 h-3" />}
|
||||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||||
@@ -473,7 +491,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
|||||||
{totalTokens > 0 && (
|
{totalTokens > 0 && (
|
||||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
<h3
|
||||||
|
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
|
||||||
|
title={t("common:insights.help.tokenFlow")}
|
||||||
|
>
|
||||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||||
{t("overview.tokenFlow")}
|
{t("overview.tokenFlow")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { useAuth } from "./AuthGate";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { WorkspaceTree } from "./WorkspaceTree";
|
import { WorkspaceTree } from "./WorkspaceTree";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
@@ -170,6 +172,10 @@ interface SidebarProps {
|
|||||||
onUpdateAvailableChange?: (available: boolean) => void;
|
onUpdateAvailableChange?: (available: boolean) => void;
|
||||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||||
drawer?: { open: boolean; onClose: () => void };
|
drawer?: { open: boolean; onClose: () => void };
|
||||||
|
/** Expanded width in px, chosen by dragging its edge (see Layout). */
|
||||||
|
width?: number;
|
||||||
|
/** True while that edge is dragged: no width transition lagging the pointer. */
|
||||||
|
resizing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -180,8 +186,11 @@ export function Sidebar({
|
|||||||
onConnectionOpenChange,
|
onConnectionOpenChange,
|
||||||
onUpdateAvailableChange,
|
onUpdateAvailableChange,
|
||||||
drawer,
|
drawer,
|
||||||
|
width = 240,
|
||||||
|
resizing = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const auth = useAuth();
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
const drawerOpen = Boolean(drawer?.open);
|
const drawerOpen = Boolean(drawer?.open);
|
||||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||||
@@ -396,13 +405,15 @@ export function Sidebar({
|
|||||||
onUpdateAvailableChange?.(updateAvailable);
|
onUpdateAvailableChange?.(updateAvailable);
|
||||||
}, [updateAvailable, onUpdateAvailableChange]);
|
}, [updateAvailable, onUpdateAvailableChange]);
|
||||||
|
|
||||||
|
// Same density and selection as a workspace row, so the whole sidebar
|
||||||
|
// reads as one list: no border, the active item tinted with an accent icon.
|
||||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
|
||||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent/10 text-accent border border-accent/20"
|
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
|
||||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -421,8 +432,9 @@ export function Sidebar({
|
|||||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||||
}`
|
}`
|
||||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
|
||||||
}`}
|
}`}
|
||||||
|
style={drawer || collapsed ? undefined : { width }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
@@ -451,7 +463,7 @@ export function Sidebar({
|
|||||||
Chevron buttons appear at the edges when content is clipped, so the
|
Chevron buttons appear at the edges when content is clipped, so the
|
||||||
user knows there's more to reach without inspecting the scrollbar. */}
|
user knows there's more to reach without inspecting the scrollbar. */}
|
||||||
<div className="flex-1 min-h-0 relative flex">
|
<div className="flex-1 min-h-0 relative flex">
|
||||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
|
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||||
const label = t(key);
|
const label = t(key);
|
||||||
return (
|
return (
|
||||||
@@ -475,7 +487,7 @@ export function Sidebar({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={t("lanes:tree.title")}
|
title={t("lanes:tree.title")}
|
||||||
aria-label={t("lanes:tree.title")}
|
aria-label={t("lanes:tree.title")}
|
||||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<FolderTree className="w-4 h-4" />
|
<FolderTree className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -522,14 +534,30 @@ export function Sidebar({
|
|||||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{auth.state.mode === "server" && auth.state.user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void auth.logout()}
|
||||||
|
title={`${t("common:auth.signedInAs", {
|
||||||
|
name: auth.state.user.username,
|
||||||
|
role: auth.state.user.role,
|
||||||
|
})} — ${t("common:auth.logout")}`}
|
||||||
|
aria-label={t("common:auth.logout")}
|
||||||
|
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||||
|
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
data-testid="sidebar-toggle"
|
data-testid="sidebar-toggle"
|
||||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @file SnapshotButton.tsx
|
||||||
|
* @description The session analysis page's **Snapshot** action: confirms with a
|
||||||
|
* "redact file paths" choice, freezes the session through `POST
|
||||||
|
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Camera } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
|
import { Checkbox } from "./Checkbox";
|
||||||
|
import { CopyButton } from "./event-views/primitives";
|
||||||
|
|
||||||
|
export function snapshotUrl(token: string): string {
|
||||||
|
return `${window.location.origin}/snapshot/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [redact, setRedact] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||||
|
setToken(snapshot.token);
|
||||||
|
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
{token && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
|
||||||
|
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
|
||||||
|
{t("snapshot.open")}
|
||||||
|
</Link>
|
||||||
|
<CopyButton text={snapshotUrl(token)} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
title={t("snapshot.hint")}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
>
|
||||||
|
<Camera className="h-4 w-4" />
|
||||||
|
{t("snapshot.create")}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={open}
|
||||||
|
title={t("snapshot.title")}
|
||||||
|
message={t("snapshot.body")}
|
||||||
|
confirmLabel={t("snapshot.confirm")}
|
||||||
|
cancelLabel={t("snapshot.cancel")}
|
||||||
|
destructive={false}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void create()}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
|
||||||
|
</ConfirmModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +94,15 @@ export function StatCard({
|
|||||||
raw,
|
raw,
|
||||||
loading = false,
|
loading = false,
|
||||||
to,
|
to,
|
||||||
|
help,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
<span
|
||||||
|
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||||
|
title={help}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||||
|
|||||||
@@ -11,22 +11,31 @@
|
|||||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||||
* whenever the URL names another workspace or session. Each figure carries its
|
* whenever the URL names another workspace or session. Each figure carries its
|
||||||
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
|
* token total, and hovering it lists tokens and cost per model. At the right
|
||||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||||
|
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||||
|
* its fullest window, live account only — and, on hover or focus, a panel
|
||||||
|
* above grouping every account per tool (saved Claude profiles too): name,
|
||||||
|
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Wifi, WifiOff } from "lucide-react";
|
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||||
import { api, type RunHandle } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import type { CostResult } from "../lib/types";
|
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||||
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||||
|
|
||||||
const COST_POLL_MS = 30_000;
|
const COST_POLL_MS = 30_000;
|
||||||
|
const QUOTA_POLL_MS = 5 * 60_000;
|
||||||
|
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||||
|
|
||||||
function localDate(d = new Date()): string {
|
function localDate(d = new Date()): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -64,6 +73,256 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
|
||||||
|
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => document.visibilityState === "visible" && load();
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, ms);
|
||||||
|
document.addEventListener("visibilitychange", tick);
|
||||||
|
return () => {
|
||||||
|
clearInterval(id);
|
||||||
|
document.removeEventListener("visibilitychange", tick);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
|
||||||
|
const quotaTone = (p: number) =>
|
||||||
|
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
|
||||||
|
const barTone = (p: number) =>
|
||||||
|
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
|
||||||
|
|
||||||
|
function untilText(iso: string | null): string | null {
|
||||||
|
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||||
|
if (!(ms > 0)) return null;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const d = Math.floor(m / 1440);
|
||||||
|
const h = Math.floor((m % 1440) / 60);
|
||||||
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "account · sign-in state · as of", whichever parts the entry has. */
|
||||||
|
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
|
||||||
|
const until = untilText(q.expiresAt ?? null);
|
||||||
|
return [
|
||||||
|
q.label && q.account,
|
||||||
|
q.expiresAt &&
|
||||||
|
q.error !== "ELIVEEXPIRED" &&
|
||||||
|
(until
|
||||||
|
? t("statusBar.quotaSignInExpires", { time: until })
|
||||||
|
: t("statusBar.quotaSignInExpired")),
|
||||||
|
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||||
|
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||||
|
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||||
|
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||||
|
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||||
|
const { t } = useTranslation("nav");
|
||||||
|
const meta = quotaMeta(q, t);
|
||||||
|
return (
|
||||||
|
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||||
|
{many && q.active !== false && (
|
||||||
|
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||||
|
{t("statusBar.quotaActive")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
|
||||||
|
{q.error && !q.windows.length ? (
|
||||||
|
<div className="text-status-warning">
|
||||||
|
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||||
|
{q.windows.map((w) => {
|
||||||
|
const p = Math.round(w.usedPercent ?? 0);
|
||||||
|
const resets = untilText(w.resetsAt);
|
||||||
|
return (
|
||||||
|
<Fragment key={w.label}>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||||
|
</span>
|
||||||
|
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
|
||||||
|
<span
|
||||||
|
className={`block h-full ${barTone(p)}`}
|
||||||
|
style={{ width: `${Math.min(p, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||||
|
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||||
|
{resets && (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||||
|
<span aria-hidden>{resets}</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("statusBar.quotaResets", { time: resets })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuotaPanel({
|
||||||
|
tools,
|
||||||
|
onEnter,
|
||||||
|
onLeave,
|
||||||
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
|
}: {
|
||||||
|
tools: ToolQuota[];
|
||||||
|
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
|
||||||
|
onRefresh: () => void;
|
||||||
|
/** A forced read is in flight: the Refresh icon spins. */
|
||||||
|
refreshing: boolean;
|
||||||
|
/** The pointer moved onto the panel / off it: it stays open while read. */
|
||||||
|
onEnter: () => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("nav");
|
||||||
|
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
|
||||||
|
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
|
||||||
|
.filter(([, list]) => list.length);
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("statusBar.quotaTitle")}
|
||||||
|
data-testid="status-bar-quota-panel"
|
||||||
|
onMouseEnter={onEnter}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
onFocus={onEnter}
|
||||||
|
onBlur={onLeave}
|
||||||
|
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||||
|
>
|
||||||
|
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||||
|
{groups.map(([id, list]) => (
|
||||||
|
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
|
||||||
|
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 pl-[1.375rem]">
|
||||||
|
{list.map((q, i) => (
|
||||||
|
<QuotaAccount
|
||||||
|
key={`${q.label ?? q.account ?? ""}-${i}`}
|
||||||
|
q={q}
|
||||||
|
many={list.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="status-bar-quota-refresh"
|
||||||
|
// Not `disabled`: a focused button that turns disabled loses focus, and
|
||||||
|
// that blur would close the panel under the pointer.
|
||||||
|
onClick={() => !refreshing && onRefresh()}
|
||||||
|
aria-busy={refreshing}
|
||||||
|
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
|
||||||
|
refreshing ? "cursor-default" : "hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
|
||||||
|
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||||
|
function QuotaChips() {
|
||||||
|
const { t } = useTranslation("nav");
|
||||||
|
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
|
const closeTimer = useRef<number>();
|
||||||
|
const show = () => {
|
||||||
|
window.clearTimeout(closeTimer.current);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const hide = () => {
|
||||||
|
window.clearTimeout(closeTimer.current);
|
||||||
|
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||||
|
};
|
||||||
|
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||||
|
const load = useCallback(
|
||||||
|
(force = false) =>
|
||||||
|
api.pricing
|
||||||
|
.quota(force)
|
||||||
|
.then((r) => setTools(r.tools))
|
||||||
|
.catch(() => undefined),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
|
if (!tools.length) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="status-bar-quota"
|
||||||
|
data-tip-off
|
||||||
|
onClick={refresh}
|
||||||
|
onMouseEnter={show}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
onFocus={show}
|
||||||
|
onBlur={hide}
|
||||||
|
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{tools
|
||||||
|
.filter((q) => q.active !== false)
|
||||||
|
.map((q) => {
|
||||||
|
const p = Math.round(fullest(q));
|
||||||
|
return (
|
||||||
|
<span key={q.id} className="flex items-center gap-1">
|
||||||
|
<AiToolIcon
|
||||||
|
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
|
||||||
|
className="h-4 w-4 !p-0"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
|
||||||
|
{q.error ? (
|
||||||
|
<span className="text-status-warning">!</span>
|
||||||
|
) : (
|
||||||
|
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<QuotaPanel
|
||||||
|
tools={tools}
|
||||||
|
onEnter={show}
|
||||||
|
onLeave={hide}
|
||||||
|
onRefresh={refresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusBar({
|
export function StatusBar({
|
||||||
wsConnected,
|
wsConnected,
|
||||||
left,
|
left,
|
||||||
@@ -85,6 +344,8 @@ export function StatusBar({
|
|||||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||||
|
// The budget furthest past one of its thresholds, if any.
|
||||||
|
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const shown = selectedFrom(location.pathname, location.search);
|
const shown = selectedFrom(location.pathname, location.search);
|
||||||
const lane = shown.lane;
|
const lane = shown.lane;
|
||||||
@@ -124,6 +385,13 @@ export function StatusBar({
|
|||||||
.totalCost({ date: localDate() })
|
.totalCost({ date: localDate() })
|
||||||
.then((r) => live && setToday(r))
|
.then((r) => live && setToday(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
api.budgets
|
||||||
|
.status()
|
||||||
|
.then(({ budgets }) => {
|
||||||
|
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||||
|
if (live) setBudget(hit[0] ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
if (lane !== null) {
|
if (lane !== null) {
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ lane })
|
.totalCost({ lane })
|
||||||
@@ -213,6 +481,21 @@ export function StatusBar({
|
|||||||
r={sessionCost}
|
r={sessionCost}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{budget && (
|
||||||
|
<Link
|
||||||
|
to="/?tab=analytics&sub=cost"
|
||||||
|
data-testid="status-bar-budget"
|
||||||
|
title={t("statusBar.budgetTitle", {
|
||||||
|
name: budget.name,
|
||||||
|
spend: usd(budget.spend),
|
||||||
|
limit: usd(budget.config.limit_usd ?? 0),
|
||||||
|
})}
|
||||||
|
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
|
||||||
|
>
|
||||||
|
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<QuotaChips />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,16 @@
|
|||||||
*
|
*
|
||||||
* ## State sources
|
* ## State sources
|
||||||
* - Initial fetch via `api.updates.status()` on mount.
|
* - Initial fetch via `api.updates.status()` on mount.
|
||||||
* - Background re-check via `api.updates.check()` every hour
|
* - No client-side background re-check: the server checks on its own
|
||||||
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
* schedule and broadcasts `update_status`; "Check now" stays manual.
|
||||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||||
*
|
*
|
||||||
* ## Dismissal persistence
|
* ## Dismissal persistence
|
||||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
* 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
|
* 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
|
* a dismissed one stays dismissed. The connection modal's "Check for updates"
|
||||||
* `dashboard:reset-update-dismissal` window event.
|
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
|
||||||
|
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -84,15 +85,13 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { toast } from "../lib/toast";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||||
const DISMISS_KEY = "agent-monitor-update-dismissed-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
|
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||||
@@ -170,21 +169,12 @@ export function UpdateNotifier() {
|
|||||||
});
|
});
|
||||||
}, [syncFromPayload]);
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
// Background re-check every hour, on top of the initial mount fetch and the
|
|
||||||
// manual "Check now" button — so a long-lived tab notices an update without
|
|
||||||
// the user having to click anything.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
// An explicit "Check for updates" undoes both kinds of close.
|
||||||
api.updates
|
const handler = () => {
|
||||||
.check()
|
setDismissedSha(null);
|
||||||
.then(syncFromPayload)
|
setClosedKey(null);
|
||||||
.catch(() => {});
|
};
|
||||||
}, AUTO_CHECK_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [syncFromPayload]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => setDismissedSha(null);
|
|
||||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -259,13 +249,30 @@ export function UpdateNotifier() {
|
|||||||
* stretch, so polling naively would get a 200 from the process we are
|
* stretch, so polling naively would get a 200 from the process we are
|
||||||
* waiting to replace and reload onto the same old bundle.
|
* waiting to replace and reload onto the same old bundle.
|
||||||
*/
|
*/
|
||||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
const pollUntilBack = useCallback(
|
||||||
|
(requireDownFirst = false, fromVersion: string | null = null) => {
|
||||||
let sawDown = !requireDownFirst;
|
let sawDown = !requireDownFirst;
|
||||||
|
// Its own verdicts only: an older plugin-update.json is still on disk
|
||||||
|
// until this chain writes one (5 s slack for a clock a little behind).
|
||||||
|
const since = Date.now() - 5000;
|
||||||
const attempt = () => {
|
const attempt = () => {
|
||||||
api.updates
|
api.updates
|
||||||
.status()
|
.status()
|
||||||
.then(() => {
|
.then((s) => {
|
||||||
if (sawDown) window.location.reload();
|
const v = s.last_plugin_update;
|
||||||
|
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||||
|
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||||
|
// never goes down, so waiting for that spun "Restarting…" forever.
|
||||||
|
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
|
||||||
|
syncFromPayload(s);
|
||||||
|
setRestarting(false);
|
||||||
|
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A new version answering is back too, even if a poll never
|
||||||
|
// landed in the short gap between the old server and the new one.
|
||||||
|
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
|
||||||
|
if (sawDown || moved) window.location.reload();
|
||||||
else setTimeout(attempt, 1500);
|
else setTimeout(attempt, 1500);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -274,7 +281,9 @@ export function UpdateNotifier() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
setTimeout(attempt, 1500);
|
setTimeout(attempt, 1500);
|
||||||
}, []);
|
},
|
||||||
|
[syncFromPayload, t]
|
||||||
|
);
|
||||||
|
|
||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
@@ -287,7 +296,7 @@ export function UpdateNotifier() {
|
|||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
// mode "plugin": the old server lingers while the chain installs, so
|
// mode "plugin": the old server lingers while the chain installs, so
|
||||||
// only treat a reachable server as "back" after it has gone away once.
|
// only treat a reachable server as "back" after it has gone away once.
|
||||||
pollUntilBack(result.mode === "plugin");
|
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(
|
setError(
|
||||||
@@ -313,7 +322,7 @@ export function UpdateNotifier() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="update-notifier-title"
|
aria-labelledby="update-notifier-title"
|
||||||
@@ -325,7 +334,7 @@ export function UpdateNotifier() {
|
|||||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||||
@@ -365,7 +374,7 @@ export function UpdateNotifier() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -6,14 +6,23 @@
|
|||||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||||
* live tab is opened from its session row. Live status comes from the shared
|
* live tab is opened from its session row. Live status comes from the shared
|
||||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||||
* lane's status or session moves and handed over by the Workspace page's own
|
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||||
* destructive modals so the row menu works from any page. Which rows are
|
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Plus, Search } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
@@ -21,6 +30,7 @@ import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
|||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||||
import {
|
import {
|
||||||
insertionIndex,
|
insertionIndex,
|
||||||
moveBefore,
|
moveBefore,
|
||||||
@@ -33,8 +43,15 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
|||||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
|
||||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
// The sidebar is in the entry chunk; its two modals (and the run form the add
|
||||||
|
// modal pulls in) load when first opened.
|
||||||
|
const AddLaneModal = lazy(() =>
|
||||||
|
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||||
|
);
|
||||||
|
const DestructiveLaneModal = lazy(() =>
|
||||||
|
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||||
|
);
|
||||||
|
|
||||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||||
@@ -72,8 +89,12 @@ export function WorkspaceTree() {
|
|||||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||||
// status or session moves, so that is when the live-pane list is re-read;
|
// status or session moves, so that is when the live-pane list is re-read;
|
||||||
// the Workspace page also hands over every run list it polls (a tab in
|
// the Workspace page also hands over every run list it polls (a tab in
|
||||||
// slots 1–3 moves no lane column).
|
// slots 1–3 moves no lane column). Throttled: with two sessions in one
|
||||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
// workspace these columns flip on every hook, and each flip was a GET.
|
||||||
|
const runKey = useThrottledValue(
|
||||||
|
lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","),
|
||||||
|
3000
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
api.run
|
api.run
|
||||||
@@ -349,6 +370,7 @@ export function WorkspaceTree() {
|
|||||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Suspense fallback={null}>
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
lane={destructive.lane}
|
lane={destructive.lane}
|
||||||
@@ -358,8 +380,9 @@ export function WorkspaceTree() {
|
|||||||
onConfirm={(body) => void confirmDestructive(body)}
|
onConfirm={(body) => void confirmDestructive(body)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{addOpen && (
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addOpen}
|
open
|
||||||
cwdSuggestions={cwdSuggestions}
|
cwdSuggestions={cwdSuggestions}
|
||||||
prefill={addPrefill}
|
prefill={addPrefill}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -371,6 +394,8 @@ export function WorkspaceTree() {
|
|||||||
select(lane.id);
|
select(lane.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the alert rule form's spend rules: a budget rule is built
|
||||||
|
* from period / limit / thresholds / AI tool (plus the browser's timezone
|
||||||
|
* offset), Preview shows what it would have fired without saving it, and
|
||||||
|
* Create saves the same config.
|
||||||
|
* @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 { MemoryRouter } from "react-router-dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
alerts: {
|
||||||
|
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
|
||||||
|
rules: {
|
||||||
|
list: vi.fn().mockResolvedValue({ rules: [] }),
|
||||||
|
create: vi.fn().mockResolvedValue({ rule: {} }),
|
||||||
|
preview: vi.fn().mockResolvedValue({
|
||||||
|
supported: true,
|
||||||
|
window_days: 7,
|
||||||
|
fires: 1,
|
||||||
|
sessions: 0,
|
||||||
|
samples: [],
|
||||||
|
periods: [
|
||||||
|
{
|
||||||
|
period_start: "2026-09-01",
|
||||||
|
period_end: "2026-09-30",
|
||||||
|
spend: 120,
|
||||||
|
pct: 120,
|
||||||
|
crossed: [80, 100],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period_start: "2026-10-01",
|
||||||
|
period_end: "2026-10-31",
|
||||||
|
spend: 10,
|
||||||
|
pct: 10,
|
||||||
|
crossed: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aiTools: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
tools: [{ id: "codex", label: "Codex" }],
|
||||||
|
default: "claude",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { AlertsNotifications } from "../AlertsNotifications";
|
||||||
|
|
||||||
|
describe("AlertsNotifications — spend rules", () => {
|
||||||
|
it("previews and creates a budget rule", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AlertsNotifications />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByText("Add rule"));
|
||||||
|
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
|
||||||
|
const selects = screen.getAllByRole("combobox");
|
||||||
|
await user.selectOptions(selects[0]!, "budget");
|
||||||
|
await user.selectOptions(screen.getByDisplayValue("month"), "week");
|
||||||
|
const limit = screen.getByDisplayValue("100");
|
||||||
|
await user.clear(limit);
|
||||||
|
await user.type(limit, "50");
|
||||||
|
await screen.findByRole("option", { name: "Codex" });
|
||||||
|
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Preview/ }));
|
||||||
|
const config = {
|
||||||
|
period: "week",
|
||||||
|
limit_usd: 50,
|
||||||
|
thresholds: [80, 100],
|
||||||
|
tz_offset: new Date().getTimezoneOffset(),
|
||||||
|
tool: "codex",
|
||||||
|
};
|
||||||
|
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
|
||||||
|
rule_type: "budget",
|
||||||
|
config,
|
||||||
|
cooldown_seconds: 300,
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("80% 100%")).toBeInTheDocument();
|
||||||
|
expect(api.alerts.rules.create).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Create rule/ }));
|
||||||
|
expect(api.alerts.rules.create).toHaveBeenCalledWith({
|
||||||
|
name: "Monthly",
|
||||||
|
rule_type: "budget",
|
||||||
|
config,
|
||||||
|
cooldown_seconds: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @file AuthGate.test.tsx
|
||||||
|
* @description The login gate: local mode renders the app untouched, server
|
||||||
|
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||||
|
* and a later 401 `EAUTH` brings the login page back.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "../../i18n";
|
||||||
|
|
||||||
|
const me = vi.fn();
|
||||||
|
const login = vi.fn();
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||||
|
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthGate } from "../AuthGate";
|
||||||
|
|
||||||
|
const user = { id: 1, username: "mia", role: "member" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
me.mockReset();
|
||||||
|
login.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AuthGate", () => {
|
||||||
|
it("renders the app in local mode", async () => {
|
||||||
|
me.mockResolvedValue({ mode: "local" });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paints at once in a browser that last saw local mode", () => {
|
||||||
|
localStorage.setItem("agenthub.authMode", "local");
|
||||||
|
me.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(screen.getByText("app")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks again instead of guessing when the server does not answer", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
me.mockRejectedValueOnce(new Error("502"));
|
||||||
|
me.mockResolvedValue({ mode: "server", user: null });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2100);
|
||||||
|
});
|
||||||
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||||
|
for (const err of [
|
||||||
|
{ status: 404 },
|
||||||
|
{ status: 200, code: "EBADRESPONSE" },
|
||||||
|
{ status: 401, code: "EUNAUTHORIZED" },
|
||||||
|
]) {
|
||||||
|
me.mockReset();
|
||||||
|
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||||
|
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login in server mode and opens the app after one", async () => {
|
||||||
|
me.mockResolvedValue({ mode: "server", user: null });
|
||||||
|
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
|
||||||
|
login.mockResolvedValueOnce({ user });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
|
||||||
|
const name = await screen.findByLabelText(/username/i);
|
||||||
|
const pass = screen.getByLabelText(/password/i);
|
||||||
|
await userEvent.type(name, "mia");
|
||||||
|
await userEvent.type(pass, "nope");
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.type(pass, "member-pass-1");
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("agenthub:auth-required"));
|
||||||
|
});
|
||||||
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file EventReplay.test.tsx
|
||||||
|
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
|
||||||
|
* marker jumping the cursor, playback advancing on a timer, and the derived
|
||||||
|
* state following the cursor.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { EventReplay } from "../EventReplay";
|
||||||
|
import type { DashboardEvent } from "../../lib/types";
|
||||||
|
|
||||||
|
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
session_id: "s1",
|
||||||
|
agent_id: "a1",
|
||||||
|
event_type,
|
||||||
|
tool_name,
|
||||||
|
summary: `${event_type} ${tool_name ?? ""}`.trim(),
|
||||||
|
data: null,
|
||||||
|
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
|
||||||
|
} as DashboardEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given newest first, as the events API answers: the replay orders them itself.
|
||||||
|
const events = [
|
||||||
|
ev(4, "Stop"),
|
||||||
|
ev(3, "PostToolUse", "Bash"),
|
||||||
|
ev(2, "PreToolUse", "Bash"),
|
||||||
|
ev(1, "SessionStart"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderReplay() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = () => screen.getByText(/^\d+ \/ 4/);
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("EventReplay", () => {
|
||||||
|
it("starts at the first event and steps with the buttons", () => {
|
||||||
|
renderReplay();
|
||||||
|
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||||
|
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||||
|
fireEvent.click(screen.getByTitle("Step forward (→)"));
|
||||||
|
expect(position().textContent).toMatch(/^3 \/ 4/);
|
||||||
|
expect(screen.getByText("Bash ×1")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByTitle("Jump to end (End)"));
|
||||||
|
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||||
|
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
|
||||||
|
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves on arrow keys and jumps from a lifecycle marker", () => {
|
||||||
|
renderReplay();
|
||||||
|
const root = screen.getByTestId("event-replay");
|
||||||
|
fireEvent.keyDown(root, { key: "ArrowRight" });
|
||||||
|
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||||
|
// The in-flight tool at the PreToolUse.
|
||||||
|
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
|
||||||
|
fireEvent.keyDown(root, { key: "End" });
|
||||||
|
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
|
||||||
|
expect(position().textContent).toMatch(/^1 \/ 4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays to the end at the chosen speed", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
renderReplay();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5×" }));
|
||||||
|
fireEvent.click(screen.getByTitle("Play (Space)"));
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
expect(position().textContent).toMatch(/^2 \/ 4/);
|
||||||
|
// One step per tick: each step schedules the next.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(position().textContent).toMatch(/^4 \/ 4/);
|
||||||
|
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
* @description Responsive shell: with no stored preference the sidebar starts
|
* @description Responsive shell: with no stored preference the sidebar starts
|
||||||
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
||||||
* from a menu button — a modal dialog that Escape and the backdrop close,
|
* from a menu button — a modal dialog that Escape and the backdrop close,
|
||||||
* handing focus back to the button.
|
* handing focus back to the button. The expanded sidebar's edge resizes it
|
||||||
|
* within its bounds and remembers the width.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
|
|||||||
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
||||||
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
||||||
|
|
||||||
import { Layout, DRAWER_QUERY } from "../Layout";
|
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
|
||||||
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
||||||
|
|
||||||
function viewport(width: number) {
|
function viewport(width: number) {
|
||||||
@@ -34,6 +35,7 @@ function viewport(width: number) {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
||||||
|
localStorage.removeItem("agenthub.sidebar.width");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadCollapsed", () => {
|
describe("loadCollapsed", () => {
|
||||||
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
|
|||||||
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Layout sidebar width", () => {
|
||||||
|
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
|
||||||
|
viewport(1440);
|
||||||
|
vi.stubGlobal("innerWidth", 2560);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Layout wsConnected />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const edge = screen.getByTestId("sidebar-resize");
|
||||||
|
const main = screen.getByRole("main");
|
||||||
|
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||||
|
|
||||||
|
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||||
|
fireEvent.pointerMove(edge, { clientX: 360 });
|
||||||
|
fireEvent.pointerUp(edge, { clientX: 360 });
|
||||||
|
expect(main.style.marginLeft).toBe("360px");
|
||||||
|
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
|
||||||
|
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
|
||||||
|
|
||||||
|
fireEvent.pointerDown(edge, { pointerId: 1 });
|
||||||
|
fireEvent.pointerUp(edge, { clientX: 2000 });
|
||||||
|
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
|
||||||
|
|
||||||
|
fireEvent.keyDown(edge, { key: "ArrowLeft" });
|
||||||
|
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
|
||||||
|
|
||||||
|
fireEvent.doubleClick(edge);
|
||||||
|
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never wider than 40% of the window", () => {
|
||||||
|
viewport(1100);
|
||||||
|
vi.stubGlobal("innerWidth", 1100);
|
||||||
|
localStorage.setItem("agenthub.sidebar.width", "480");
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Layout wsConnected />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
* state with the version (click opens the connection details), the running and
|
* state with the version (click opens the connection details), the running and
|
||||||
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
|
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
|
||||||
* with its tokens and a per-model hover list, and on the Workspace page the
|
* with its tokens and a per-model hover list, and on the Workspace page the
|
||||||
* open workspace's and the shown session's totals.
|
* open workspace's and the shown session's totals; the right-end quota chips
|
||||||
|
* (fullest window per tool) and their hover panel.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
@@ -68,12 +69,63 @@ vi.mock("../../lib/api", () => ({
|
|||||||
],
|
],
|
||||||
daily_costs: [],
|
daily_costs: [],
|
||||||
})),
|
})),
|
||||||
|
quota: vi.fn().mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
active: true,
|
||||||
|
label: "Work",
|
||||||
|
account: "me@example.com",
|
||||||
|
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: "5h",
|
||||||
|
usedPercent: 95,
|
||||||
|
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
|
||||||
|
},
|
||||||
|
{ label: "wk", usedPercent: 32, resetsAt: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
active: false,
|
||||||
|
label: "Personal",
|
||||||
|
account: "home@example.com",
|
||||||
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||||
|
windows: [],
|
||||||
|
error: "EPROFILEEXPIRED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
account: "dev@example.com",
|
||||||
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
||||||
|
windows: [],
|
||||||
|
error: "EUPSTREAM",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkedAt: "",
|
||||||
|
}),
|
||||||
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
||||||
total_cost: id === "s-live" ? 4.5 : 7,
|
total_cost: id === "s-live" ? 4.5 : 7,
|
||||||
breakdown: [],
|
breakdown: [],
|
||||||
daily_costs: [],
|
daily_costs: [],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
budgets: {
|
||||||
|
status: vi.fn().mockResolvedValue({
|
||||||
|
budgets: [
|
||||||
|
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
|
||||||
|
{
|
||||||
|
id: "b2",
|
||||||
|
name: "Monthly",
|
||||||
|
config: { limit_usd: 100 },
|
||||||
|
spend: 85,
|
||||||
|
pct: 85,
|
||||||
|
crossed: [80],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -105,6 +157,14 @@ describe("StatusBar", () => {
|
|||||||
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
|
||||||
|
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||||
|
const chip = await screen.findByTestId("status-bar-budget");
|
||||||
|
expect(chip.textContent).toBe("Budget 85%");
|
||||||
|
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
|
||||||
|
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||||
|
});
|
||||||
|
|
||||||
it("says Disconnected when the socket is down", () => {
|
it("says Disconnected when the socket is down", () => {
|
||||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||||
@@ -144,4 +204,56 @@ describe("StatusBar", () => {
|
|||||||
});
|
});
|
||||||
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
|
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows each tool's fullest quota window and the details on hover", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||||
|
const chips = await screen.findByTestId("status-bar-quota");
|
||||||
|
expect(chips).toHaveTextContent("Claude95% used");
|
||||||
|
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
|
||||||
|
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||||
|
await user.hover(chips);
|
||||||
|
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||||
|
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||||
|
expect(work).toHaveTextContent("Workin use");
|
||||||
|
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||||
|
expect(work).toHaveTextContent("5h95%41m");
|
||||||
|
expect(work).toHaveTextContent("Week32%");
|
||||||
|
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||||
|
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||||
|
expect(personal).not.toHaveTextContent("in use");
|
||||||
|
expect(codex).toHaveTextContent("dev@example.com");
|
||||||
|
expect(codex).toHaveTextContent("Sign-in expired");
|
||||||
|
expect(panel).toHaveTextContent("Could not read the quota");
|
||||||
|
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||||
|
await user.unhover(chips);
|
||||||
|
await user.hover(panel);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||||
|
// Only the footer button refreshes: it spins and says so until the read lands.
|
||||||
|
const same = await api.pricing.quota(false);
|
||||||
|
let answer!: (v: unknown) => void;
|
||||||
|
vi.mocked(api.pricing.quota)
|
||||||
|
.mockClear()
|
||||||
|
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
|
||||||
|
const refresh = screen.getByTestId("status-bar-quota-refresh");
|
||||||
|
await user.click(refresh);
|
||||||
|
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(refresh).toHaveTextContent("Refreshing…");
|
||||||
|
expect(refresh).toHaveAttribute("aria-busy", "true");
|
||||||
|
await user.click(refresh);
|
||||||
|
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => answer(same));
|
||||||
|
// The spin outlasts an instant answer (a throttled refresh is served from
|
||||||
|
// cache), so the label returns after the minimum spin.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||||
|
await user.unhover(panel);
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||||
|
vi.mocked(api.pricing.quota).mockClear();
|
||||||
|
await user.click(chips);
|
||||||
|
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — plugin chain verdict", () => {
|
||||||
|
it("stops Restarting when the chain fails and the old server stays up", async () => {
|
||||||
|
const PLUGIN: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(PLUGIN);
|
||||||
|
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
|
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||||
|
|
||||||
|
// The old server answers again, now carrying this chain's failed verdict.
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...PLUGIN,
|
||||||
|
last_plugin_update: {
|
||||||
|
ok: false,
|
||||||
|
step: "plugin_update",
|
||||||
|
error: "boom",
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("UpdateNotifier — closing vs skipping", () => {
|
describe("UpdateNotifier — closing vs skipping", () => {
|
||||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||||
const { unmount } = render(<UpdateNotifier />);
|
const { unmount } = render(<UpdateNotifier />);
|
||||||
@@ -238,6 +272,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
|
||||||
|
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores Escape while an update is applying", async () => {
|
it("ignores Escape while an update is applying", async () => {
|
||||||
applyMock.mockReturnValue(new Promise(() => {}));
|
applyMock.mockReturnValue(new Promise(() => {}));
|
||||||
render(<UpdateNotifier />);
|
render(<UpdateNotifier />);
|
||||||
|
|||||||
@@ -82,12 +82,13 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
cwds: vi.fn(async () => ({ items: [] })),
|
cwds: vi.fn(async () => ({ items: [] })),
|
||||||
kill: vi.fn(async () => ({ ok: true })),
|
kill: vi.fn(async () => ({ ok: true })),
|
||||||
history: vi.fn(async () => ({ items: [] })),
|
history: vi.fn(async () => ({ items: [] })),
|
||||||
|
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||||
},
|
},
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||||
iconUrl: () => "",
|
iconUrl: () => "",
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a closed terminal from its hover X and its right-click menu", async () => {
|
||||||
|
const shell = (id: string) => ({
|
||||||
|
id,
|
||||||
|
tool: "shell",
|
||||||
|
status: "exited",
|
||||||
|
started_at: "2026-10-06T08:00:00Z",
|
||||||
|
ended_at: "2026-10-06T08:05:00Z",
|
||||||
|
isLive: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.run.history).mockImplementation(
|
||||||
|
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
||||||
|
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
||||||
|
await user.click(screen.getByText("Remove from list"));
|
||||||
|
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
it("selects by URL, and a click selects and expands", async () => {
|
it("selects by URL, and a click selects and expands", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree("/workspace?lane=2");
|
renderTree("/workspace?lane=2");
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="cc-confirm-delete-title"
|
aria-labelledby="cc-confirm-delete-title"
|
||||||
aria-describedby="cc-confirm-delete-body"
|
aria-describedby="cc-confirm-delete-body"
|
||||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
|||||||
{t("edit.confirmDelete")}
|
{t("edit.confirmDelete")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||||
{t("edit.confirmDeleteBody")}
|
{t("edit.confirmDeleteBody")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { api } from "../../lib/api";
|
|||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
@@ -106,7 +107,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
const genRef = useRef(0);
|
const genRef = useRef(0);
|
||||||
// Refresh-button spinner state - separate from initial `loading` so the
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// existing skeleton doesn't blink during a manual refresh.
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
// Load available transcript list (also rescanned on a short interval so
|
// Load available transcript list (also rescanned on a short interval so
|
||||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||||
@@ -242,6 +242,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
// also poll below to catch what WS misses.
|
// also poll below to catch what WS misses.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||||
|
// Hidden tab: the visibility handler below catches up once on show.
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
if (isRemoteDataRefreshMessage(msg)) {
|
||||||
fetchNewMessages();
|
fetchNewMessages();
|
||||||
return;
|
return;
|
||||||
@@ -303,14 +305,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
|
|
||||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
const refresh = useCallback(async () => {
|
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await fetchNewMessages();
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [fetchNewMessages]);
|
|
||||||
|
|
||||||
// Scroll-up to load history
|
// Scroll-up to load history
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
|
||||||
|
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||||
|
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||||
|
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||||
|
* original read-only on the left beside the editable file (`MergeView`).
|
||||||
|
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||||
|
* and every changed block carries a Revert that puts the original back in the
|
||||||
|
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||||
|
* a line removed whole, blue a line edited in place (it keeps some of its
|
||||||
|
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||||
|
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||||
|
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||||
|
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||||
|
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
drawSelection,
|
||||||
|
highlightActiveLine,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
type DecorationSet,
|
||||||
|
type ViewUpdate,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
|
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||||
|
import { highlight, tokenClass } from "../../lib/highlight";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a letter or digit of a line (`text`, starting at document offset
|
||||||
|
* `from`) lies outside every changed range: an edit inside the line, not a
|
||||||
|
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
|
||||||
|
* a `}`) does not make two lines the same line.
|
||||||
|
*/
|
||||||
|
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
if (!/\w/.test(text[i]!)) continue;
|
||||||
|
const at = from + i;
|
||||||
|
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modLine = Decoration.line({ class: "cm-ah-mod" });
|
||||||
|
const marks = new Map<string, Decoration>();
|
||||||
|
const mark = (cls: string) => {
|
||||||
|
let d = marks.get(cls);
|
||||||
|
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Syntax marks and the edited-in-place class for the visible lines — on
|
||||||
|
* either side of a split view (`a` is the original). */
|
||||||
|
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||||
|
const out = new RangeSetBuilder<Decoration>();
|
||||||
|
const info = getChunks(view.state);
|
||||||
|
const a = info?.side === "a";
|
||||||
|
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||||
|
const chunks = info?.chunks ?? [];
|
||||||
|
const doc = view.state.doc;
|
||||||
|
let last = 0;
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
for (let pos = from; pos <= to; ) {
|
||||||
|
const line = doc.lineAt(pos);
|
||||||
|
pos = line.to + 1;
|
||||||
|
if (line.number <= last) continue;
|
||||||
|
last = line.number;
|
||||||
|
const c = chunks.find((k) => {
|
||||||
|
const [from, to, end] = span(k);
|
||||||
|
return to! > from! && line.from >= from! && line.from <= end!;
|
||||||
|
});
|
||||||
|
const at0 = c ? span(c)[0]! : 0;
|
||||||
|
if (
|
||||||
|
c &&
|
||||||
|
keepsText(
|
||||||
|
line.text,
|
||||||
|
line.from,
|
||||||
|
c.changes.map((ch) =>
|
||||||
|
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
out.add(line.from, line.from, modLine);
|
||||||
|
}
|
||||||
|
let at = line.from;
|
||||||
|
for (const tok of highlight(line.text, lang)) {
|
||||||
|
const cls = tokenClass(tok.type);
|
||||||
|
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
|
||||||
|
at += tok.text.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
|
||||||
|
* classed after the fact, by `keepsText`'s rule: text outside its
|
||||||
|
* `.cm-deletedText` spans is text the line kept. */
|
||||||
|
function classDeleted(root: HTMLElement) {
|
||||||
|
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
|
||||||
|
let kept = "";
|
||||||
|
(line.firstElementChild ?? line).childNodes.forEach((n) => {
|
||||||
|
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
|
||||||
|
kept += n.textContent ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffColours = (lang: string) =>
|
||||||
|
ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = decorate(view, lang);
|
||||||
|
this.classLater(view);
|
||||||
|
}
|
||||||
|
update(u: ViewUpdate) {
|
||||||
|
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
|
||||||
|
this.classLater(u.view);
|
||||||
|
}
|
||||||
|
// After CodeMirror has drawn, and before the browser paints.
|
||||||
|
classLater(view: EditorView) {
|
||||||
|
view.requestMeasure({
|
||||||
|
key: this,
|
||||||
|
read: () => null,
|
||||||
|
write: () => classDeleted(view.contentDOM),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ decorations: (p) => p.decorations }
|
||||||
|
);
|
||||||
|
|
||||||
|
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||||
|
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||||
|
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||||
|
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||||
|
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
|
||||||
|
// base theme, whose selectors carry an extra light/dark scope class.
|
||||||
|
const M = "&.cm-merge-b.cm-merge-b";
|
||||||
|
const A = "&.cm-merge-a.cm-merge-a";
|
||||||
|
const theme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "11px",
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
color: "rgb(var(--fg-primary))",
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
||||||
|
lineHeight: "1.45",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
color: "rgb(var(--fg-muted))",
|
||||||
|
border: "none",
|
||||||
|
},
|
||||||
|
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||||
|
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||||
|
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||||
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||||
|
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||||
|
},
|
||||||
|
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||||
|
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||||
|
[`${M} .cm-changedText`]: { background: "none" },
|
||||||
|
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||||
|
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
|
||||||
|
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
|
||||||
|
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
|
||||||
|
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
|
||||||
|
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
|
||||||
|
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
|
||||||
|
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||||
|
// The original, left of a split view: its changed lines are removals.
|
||||||
|
[`${A} .cm-changedLine`]: { backgroundColor: RED },
|
||||||
|
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||||
|
[`${A} .cm-changedText`]: { background: "none" },
|
||||||
|
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||||
|
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||||
|
"&.cm-editor.cm-editor .cm-collapsedLines": {
|
||||||
|
color: "rgb(var(--fg-muted))",
|
||||||
|
background: "rgb(var(--surface-2))",
|
||||||
|
},
|
||||||
|
".cm-chunkButtons": { zIndex: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||||
|
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||||
|
function revertButton(label: string, arrow = false) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.textContent = arrow ? "→" : label;
|
||||||
|
b.title = label;
|
||||||
|
b.setAttribute("aria-label", label);
|
||||||
|
b.className =
|
||||||
|
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiffEditor({
|
||||||
|
value,
|
||||||
|
original,
|
||||||
|
lang,
|
||||||
|
layout = "unified",
|
||||||
|
collapse = false,
|
||||||
|
label,
|
||||||
|
revertLabel,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
/** The text the diff is taken against; read once, at mount. */
|
||||||
|
original: string;
|
||||||
|
lang: string;
|
||||||
|
/** One editor with removals inline, or the original beside the file. */
|
||||||
|
layout?: "unified" | "split";
|
||||||
|
/** Fold unchanged stretches away: only the changes and their context. */
|
||||||
|
collapse?: boolean;
|
||||||
|
label: string;
|
||||||
|
/** The text of each changed block's Revert button. */
|
||||||
|
revertLabel: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const view = useRef<EditorView | null>(null);
|
||||||
|
const cb = useRef({ onChange, onSave });
|
||||||
|
cb.current = { onChange, onSave };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||||
|
const common = [lineNumbers(), diffColours(lang), theme];
|
||||||
|
const editing = [
|
||||||
|
...common,
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: "Mod-s",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
cb.current.onSave();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indentWithTab,
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
]),
|
||||||
|
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let v: EditorView;
|
||||||
|
let destroy: () => void;
|
||||||
|
if (layout === "split") {
|
||||||
|
const mv = new MergeView({
|
||||||
|
parent: host.current!,
|
||||||
|
a: {
|
||||||
|
doc: original,
|
||||||
|
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||||
|
},
|
||||||
|
b: { doc: value, extensions: editing },
|
||||||
|
revertControls: "a-to-b",
|
||||||
|
renderRevertControl: () => revertButton(revertLabel, true),
|
||||||
|
collapseUnchanged: folded,
|
||||||
|
});
|
||||||
|
v = mv.b;
|
||||||
|
destroy = () => mv.destroy();
|
||||||
|
} else {
|
||||||
|
v = new EditorView({
|
||||||
|
parent: host.current!,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
...editing,
|
||||||
|
unifiedMergeView({
|
||||||
|
original,
|
||||||
|
syntaxHighlightDeletions: false,
|
||||||
|
collapseUnchanged: folded,
|
||||||
|
// Accept would only hide a block; Revert is the one that edits.
|
||||||
|
mergeControls: (type, action) => {
|
||||||
|
const b = revertButton(revertLabel);
|
||||||
|
if (type === "accept") b.hidden = true;
|
||||||
|
else b.onmousedown = action;
|
||||||
|
return b;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
destroy = () => v.destroy();
|
||||||
|
}
|
||||||
|
v.focus();
|
||||||
|
view.current = v;
|
||||||
|
return destroy;
|
||||||
|
// One mount per view: the parent keys this component by file and shape.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||||
|
useEffect(() => {
|
||||||
|
const v = view.current;
|
||||||
|
if (v && v.state.doc.toString() !== value) {
|
||||||
|
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={host}
|
||||||
|
data-testid="lane-file-editor"
|
||||||
|
data-layout={layout}
|
||||||
|
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
RUNTIME_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
LOCKS_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
|
|||||||
export const ROW_PX = 16;
|
export const ROW_PX = 16;
|
||||||
const OVERSCAN = 20;
|
const OVERSCAN = 20;
|
||||||
const FIXED: CSSProperties = { height: ROW_PX };
|
const FIXED: CSSProperties = { height: ROW_PX };
|
||||||
|
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||||
@@ -165,7 +166,7 @@ function Windowed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||||
function langFor(filePath: string) {
|
export function langFor(filePath: string) {
|
||||||
const ext = filePath.split(".").pop() || "";
|
const ext = filePath.split(".").pop() || "";
|
||||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||||
}
|
}
|
||||||
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Code text={text.slice(0, from)} lang={lang} />
|
<Code text={text.slice(0, from)} lang={lang} />
|
||||||
<span className="rounded-sm bg-fg-primary/15">
|
<span className="rounded-sm bg-diff-mod/40">
|
||||||
<Code text={text.slice(from, to)} lang={lang} />
|
<Code text={text.slice(from, to)} lang={lang} />
|
||||||
</span>
|
</span>
|
||||||
<Code text={text.slice(to)} lang={lang} />
|
<Code text={text.slice(to)} lang={lang} />
|
||||||
@@ -209,9 +210,10 @@ function emphasis(
|
|||||||
while (start < max && del[start] === add[start]) start += 1;
|
while (start < max && del[start] === add[start]) start += 1;
|
||||||
let end = 0;
|
let end = 0;
|
||||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
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
|
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
|
||||||
// highlighting all of it adds noise on top of the row tint.
|
// count): the whole line is the change — a removed line and an added one,
|
||||||
if (start === 0 && end === 0) return null;
|
// not an edit — and highlighting all of it adds noise on top of the row tint.
|
||||||
|
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
|
||||||
return {
|
return {
|
||||||
del: [start, del.length - end],
|
del: [start, del.length - end],
|
||||||
add: [start, add.length - end],
|
add: [start, add.length - end],
|
||||||
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
/** 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. */
|
* `style` pins a windowed row's height; an unwindowed row is at least one
|
||||||
|
* line tall, so a split view's empty filler row keeps its column in step. */
|
||||||
function Row({
|
function Row({
|
||||||
tint,
|
tint,
|
||||||
style,
|
style,
|
||||||
@@ -310,7 +313,7 @@ function Row({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex w-max min-w-full ${tint}`}
|
className={`flex w-max min-w-full ${tint}`}
|
||||||
style={style}
|
style={style ?? MIN_ROW}
|
||||||
data-diff-item={style ? "" : undefined}
|
data-diff-item={style ? "" : undefined}
|
||||||
data-change-index={anchor}
|
data-change-index={anchor}
|
||||||
>
|
>
|
||||||
@@ -320,15 +323,19 @@ function Row({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
/** 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). */
|
* separate from context at a glance on every theme (the /10 fill alone did not).
|
||||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
|
||||||
|
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
|
||||||
|
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
|
||||||
|
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
|
||||||
if (type === "add")
|
if (type === "add")
|
||||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
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))]";
|
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerClass(type: LaneDiffLine["type"]) {
|
function markerClass(type: LaneDiffLine["type"], mod = false) {
|
||||||
|
if (mod && type !== "ctx") return "font-bold text-diff-mod";
|
||||||
if (type === "add") return "font-bold text-status-success";
|
if (type === "add") return "font-bold text-status-success";
|
||||||
if (type === "del") return "font-bold text-status-danger";
|
if (type === "del") return "font-bold text-status-danger";
|
||||||
return "text-fg-muted";
|
return "text-fg-muted";
|
||||||
@@ -485,11 +492,11 @@ function UnifiedLine({
|
|||||||
}) {
|
}) {
|
||||||
const anchor = useAnchor(start);
|
const anchor = useAnchor(start);
|
||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
|
||||||
<Gutter n={line.oldLine} />
|
<Gutter n={line.oldLine} />
|
||||||
<Gutter n={line.newLine} commentable />
|
<Gutter n={line.newLine} commentable />
|
||||||
<span className="whitespace-pre px-2">
|
<span className="whitespace-pre px-2">
|
||||||
<span className={`select-none ${markerClass(line.type)}`}>
|
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
|
||||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||||
</span>
|
</span>
|
||||||
<Code text={line.text} lang={lang} emph={emph} />
|
<Code text={line.text} lang={lang} emph={emph} />
|
||||||
@@ -558,7 +565,7 @@ function SplitLine({
|
|||||||
const line = side === "left" ? row.left : row.right;
|
const line = side === "left" ? row.left : row.right;
|
||||||
const anchor = useAnchor(start);
|
const anchor = useAnchor(start);
|
||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||||
<Gutter
|
<Gutter
|
||||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||||
commentable={side === "right"}
|
commentable={side === "right"}
|
||||||
@@ -941,16 +948,12 @@ export function LaneFileView({
|
|||||||
lines,
|
lines,
|
||||||
truncated,
|
truncated,
|
||||||
fill = false,
|
fill = false,
|
||||||
onEditAt,
|
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
lines: string[];
|
lines: string[];
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
/** Take the parent's height instead of the 60vh cap. */
|
/** Take the parent's height instead of the 60vh cap. */
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
/** Set when the file is editable: a click on the text (0-based line, and
|
|
||||||
* where the view was scrolled) asks to open the editor there. */
|
|
||||||
onEditAt?: (line: number, scrollTop: number) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const lang = langFor(path);
|
const lang = langFor(path);
|
||||||
@@ -959,40 +962,17 @@ export function LaneFileView({
|
|||||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||||
<Row key={i} tint="" style={style}>
|
<Row key={i} tint="" style={style}>
|
||||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
|
||||||
<span data-ln={i} className="contents">
|
|
||||||
<Gutter n={i + 1} />
|
<Gutter n={i + 1} />
|
||||||
<span className="whitespace-pre px-2">
|
<span className="whitespace-pre px-2">
|
||||||
<Code text={text} lang={lang} />
|
<Code text={text} lang={lang} />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="lane-file-view"
|
data-testid="lane-file-view"
|
||||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||||
tabIndex={onEditAt ? 0 : undefined}
|
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||||
onClick={
|
|
||||||
onEditAt &&
|
|
||||||
((e) => {
|
|
||||||
// A click that ends a drag-selection is copying, not asking to edit.
|
|
||||||
if (window.getSelection()?.toString()) return;
|
|
||||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
|
||||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onKeyDown={
|
|
||||||
onEditAt &&
|
|
||||||
((e) => {
|
|
||||||
if (e.key !== "Enter") return;
|
|
||||||
e.preventDefault();
|
|
||||||
onEditAt(0, e.currentTarget.scrollTop);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
|
||||||
onEditAt ? " cursor-text" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{win.on ? (
|
{win.on ? (
|
||||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||||
* head, subject, and the uncommitted counts) and the git action bar under it
|
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
|
||||||
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
|
* uncommitted counts drawn vertical on the CLOSED Changes rail
|
||||||
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
|
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
|
||||||
* the open panel lists those files itself, so its header chip opens nothing.
|
* by the real total, listing the server's first 20, its "… more files" opening
|
||||||
|
* the Changes panel — the open panel lists those files itself, so its header
|
||||||
|
* shows no counts.
|
||||||
*
|
*
|
||||||
* It used to sit in the lane card, a column away from the diff it describes —
|
* 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.
|
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||||
@@ -63,7 +65,10 @@ export function useLaneGitFacts(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
GIT_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -102,6 +107,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
|
|||||||
function LaneGitFilesPanel({
|
function LaneGitFilesPanel({
|
||||||
files,
|
files,
|
||||||
hidden,
|
hidden,
|
||||||
|
onShowAll,
|
||||||
anchor,
|
anchor,
|
||||||
side = "below",
|
side = "below",
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
@@ -109,6 +115,8 @@ function LaneGitFilesPanel({
|
|||||||
}: {
|
}: {
|
||||||
files: LaneGitFile[];
|
files: LaneGitFile[];
|
||||||
hidden: number;
|
hidden: number;
|
||||||
|
/** Opens the Changes panel, which lists every file the cap left out. */
|
||||||
|
onShowAll?: () => void;
|
||||||
/** The counts button this panel hangs off. */
|
/** The counts button this panel hangs off. */
|
||||||
anchor: HTMLElement | null;
|
anchor: HTMLElement | null;
|
||||||
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
||||||
@@ -146,13 +154,14 @@ function LaneGitFilesPanel({
|
|||||||
// partial one. `hidden` says how many are missing.
|
// partial one. `hidden` says how many are missing.
|
||||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||||
|
const total = files.length + hidden;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
data-testid="lane-git-files"
|
data-testid="lane-git-files"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={t("git.filesHeading", { count: files.length })}
|
aria-label={t("git.filesHeading", { count: total })}
|
||||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
// 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.
|
// the panel answers the same enter/leave pair the trigger does.
|
||||||
onMouseEnter={onPointerEnter}
|
onMouseEnter={onPointerEnter}
|
||||||
@@ -169,7 +178,7 @@ function LaneGitFilesPanel({
|
|||||||
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"
|
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]">
|
<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="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
|
||||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||||
<span className="text-status-success">+{added}</span>{" "}
|
<span className="text-status-success">+{added}</span>{" "}
|
||||||
<span className="text-status-danger">−{deleted}</span>
|
<span className="text-status-danger">−{deleted}</span>
|
||||||
@@ -206,30 +215,40 @@ function LaneGitFilesPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{hidden > 0 && (
|
{hidden > 0 &&
|
||||||
|
(onShowAll ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-git-files-more"
|
||||||
|
onClick={onShowAll}
|
||||||
|
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("git.filesMore", { count: hidden })}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||||
{t("git.filesMore", { count: hidden })}
|
{t("git.filesMore", { count: hidden })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The uncommitted counts as a chip. With `details` it opens the per-file panel
|
* The uncommitted counts as a chip that opens the per-file panel on hover,
|
||||||
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
|
* focus or click — the closed Changes rail, `vertical`, where nothing else
|
||||||
* else lists the files. In the open panel the list below already does, so the
|
* lists the files.
|
||||||
* header shows the chip alone.
|
|
||||||
*/
|
*/
|
||||||
export function LaneGitCounts({
|
export function LaneGitCounts({
|
||||||
git,
|
git,
|
||||||
vertical = false,
|
vertical = false,
|
||||||
details = true,
|
onShowAll,
|
||||||
}: {
|
}: {
|
||||||
git: Extract<LaneGitFacts, { available: true }>;
|
git: Extract<LaneGitFacts, { available: true }>;
|
||||||
vertical?: boolean;
|
vertical?: boolean;
|
||||||
details?: boolean;
|
/** See `LaneGitFilesPanel`. */
|
||||||
|
onShowAll?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [filesOpen, setFilesOpen] = useState(false);
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
@@ -253,13 +272,6 @@ export function LaneGitCounts({
|
|||||||
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
||||||
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
||||||
}`;
|
}`;
|
||||||
if (!details) {
|
|
||||||
return (
|
|
||||||
<span data-testid="lane-git-counts" className={chip}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -288,6 +300,13 @@ export function LaneGitCounts({
|
|||||||
<LaneGitFilesPanel
|
<LaneGitFilesPanel
|
||||||
files={git.files}
|
files={git.files}
|
||||||
hidden={git.dirty + git.untracked - git.files.length}
|
hidden={git.dirty + git.untracked - git.files.length}
|
||||||
|
onShowAll={
|
||||||
|
onShowAll &&
|
||||||
|
(() => {
|
||||||
|
setFilesOpen(false);
|
||||||
|
onShowAll();
|
||||||
|
})
|
||||||
|
}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
side={vertical ? "left" : "below"}
|
side={vertical ? "left" : "below"}
|
||||||
onPointerEnter={openFiles}
|
onPointerEnter={openFiles}
|
||||||
@@ -300,9 +319,18 @@ export function LaneGitCounts({
|
|||||||
|
|
||||||
/** The counts on the closed Changes rail: its own facts read, polled only
|
/** The counts on the closed Changes rail: its own facts read, polled only
|
||||||
* while the rail is shown (the open panel owns the read otherwise). */
|
* while the rail is shown (the open panel owns the read otherwise). */
|
||||||
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
|
export function LaneGitRailCounts({
|
||||||
|
laneId,
|
||||||
|
session,
|
||||||
|
onShowAll,
|
||||||
|
}: {
|
||||||
|
laneId: number;
|
||||||
|
session?: string;
|
||||||
|
/** Opens the Changes panel from the file list's "… more files". */
|
||||||
|
onShowAll?: () => void;
|
||||||
|
}) {
|
||||||
const [git] = useLaneGitFacts(laneId, session);
|
const [git] = useLaneGitFacts(laneId, session);
|
||||||
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
|
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LaneGitHeader({
|
export function LaneGitHeader({
|
||||||
@@ -339,7 +367,6 @@ export function LaneGitHeader({
|
|||||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<LaneGitCounts git={git} details={false} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function SessionReplay({
|
|||||||
? { runId: holder.id, laneId: lane.id, held: true }
|
? { runId: holder.id, laneId: lane.id, held: true }
|
||||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
onResumed(r.runId, r.laneId);
|
onResumed(r.runId, r.laneId);
|
||||||
// Held already: the prompt reached nothing yet — paste it there.
|
// Held already: the prompt reached nothing yet — paste it there.
|
||||||
|
|||||||
@@ -26,15 +26,20 @@ import { ConfirmModal } from "../ConfirmModal";
|
|||||||
import { startInFreeTabDetailed } from "./startSession";
|
import { startInFreeTabDetailed } from "./startSession";
|
||||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||||
|
|
||||||
|
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||||
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
|
|
||||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||||
* same path-boundary rule the server binds hook sessions with. */
|
* same path-boundary rule the server binds hook sessions with. */
|
||||||
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
||||||
let best: Lane | null = null;
|
let best: Lane | null = null;
|
||||||
|
const target = strip(cwd);
|
||||||
for (const l of lanes) {
|
for (const l of lanes) {
|
||||||
if (l.tool !== tool) continue;
|
if (l.tool !== tool) continue;
|
||||||
const root = l.cwd.replace(/\/+$/, "");
|
const root = strip(l.cwd);
|
||||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
|
||||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
continue;
|
||||||
|
if (!best || root.length > strip(best.cwd).length) best = l;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
@@ -74,7 +79,7 @@ export function SessionResumeButton({
|
|||||||
owningLane(session.cwd, tool, lanes));
|
owningLane(session.cwd, tool, lanes));
|
||||||
if (!lane || runs === null) return null;
|
if (!lane || runs === null) return null;
|
||||||
const run = liveRunFor(lane, runs, session.id);
|
const run = liveRunFor(lane, runs, session.id);
|
||||||
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||||
// Its own worktree counts too: the server resumes it there (`claude
|
// Its own worktree counts too: the server resumes it there (`claude
|
||||||
// --worktree` sessions), and refuses one whose worktree is gone.
|
// --worktree` sessions), and refuses one whose worktree is gone.
|
||||||
const resumable =
|
const resumable =
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* `ConsoleTabs.tsx`). Inside
|
* `ConsoleTabs.tsx`). Inside
|
||||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
@@ -58,7 +58,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
|||||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
@@ -125,27 +125,43 @@ function HomeBody({
|
|||||||
const entry = aiTools.byId(tool);
|
const entry = aiTools.byId(tool);
|
||||||
const claude = tool === "claude";
|
const claude = tool === "claude";
|
||||||
const codex = tool === "codex";
|
const codex = tool === "codex";
|
||||||
const [prompt, setPrompt] = useState("");
|
// The half-typed prompt survives leaving the page or the workspace.
|
||||||
|
const draftKey = `agenthub.workspace.draft.${lane.id}`;
|
||||||
|
const [prompt, setPrompt] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(draftKey) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (prompt) localStorage.setItem(draftKey, prompt);
|
||||||
|
else localStorage.removeItem(draftKey);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable: the draft just does not survive */
|
||||||
|
}
|
||||||
|
}, [draftKey, prompt]);
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [router, setRouter] = useSessionRouter();
|
const [proxy, setProxy] = useSessionProxy();
|
||||||
const routers = useAiRouters();
|
const proxies = useAiProxies();
|
||||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||||
const profile = routerCapable
|
const profile = proxyCapable
|
||||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const routerModels: ModelChoice[] | undefined = profile
|
const proxyModels: ModelChoice[] | undefined = profile
|
||||||
? [
|
? [
|
||||||
...new Set(
|
...new Set(
|
||||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||||
Boolean
|
(m): m is string => !!m
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
].map((id) => ({ id, label: id }))
|
].map((id) => ({ id, label: id }))
|
||||||
: undefined;
|
: undefined;
|
||||||
// The tool's default model (also when the catalog arrives after mount); on
|
// The tool's default model (also when the catalog arrives after mount); on
|
||||||
// a router, the router's own default. A new tool or router drops the pick.
|
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
|
||||||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||||
const [model, setModel] = useState(defaultModel);
|
const [model, setModel] = useState(defaultModel);
|
||||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||||||
@@ -186,6 +202,12 @@ function HomeBody({
|
|||||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [ctxDrag, setCtxDrag] = useState(false);
|
||||||
|
const addFiles = async (picked: File[]) => {
|
||||||
|
for (const f of picked) {
|
||||||
|
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||||
const recentCtx = useContextMenu<Session>();
|
const recentCtx = useContextMenu<Session>();
|
||||||
@@ -303,7 +325,7 @@ function HomeBody({
|
|||||||
// Only a different tool is named: the workspace's own keeps starting
|
// Only a different tool is named: the workspace's own keeps starting
|
||||||
// even after it was disabled in Settings.
|
// even after it was disabled in Settings.
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
...routerArg(routerCapable, router),
|
...proxyArg(proxyCapable, proxy),
|
||||||
...(hasModel && model ? { model } : {}),
|
...(hasModel && model ? { model } : {}),
|
||||||
...(claude ? { permissionMode: mode } : {}),
|
...(claude ? { permissionMode: mode } : {}),
|
||||||
...(codex ? { sandbox, approval } : {}),
|
...(codex ? { sandbox, approval } : {}),
|
||||||
@@ -312,7 +334,7 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -517,7 +539,7 @@ function HomeBody({
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={setModel}
|
onChange={setModel}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
choices={routerModels}
|
choices={proxyModels}
|
||||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -540,12 +562,12 @@ function HomeBody({
|
|||||||
data-testid="workspace-home-options"
|
data-testid="workspace-home-options"
|
||||||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||||||
>
|
>
|
||||||
<RouterPicker
|
<ProxyPicker
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
tool={tool}
|
tool={tool}
|
||||||
routers={routers}
|
proxies={proxies}
|
||||||
value={router}
|
value={proxy}
|
||||||
onChange={setRouter}
|
onChange={setProxy}
|
||||||
/>
|
/>
|
||||||
{claude && (
|
{claude && (
|
||||||
<Select<PermissionMode>
|
<Select<PermissionMode>
|
||||||
@@ -823,7 +845,26 @@ function HomeBody({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={card}>
|
<section
|
||||||
|
data-testid="workspace-context-files"
|
||||||
|
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||||
|
// Files dragged in from the OS are added like the + button's pick.
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setCtxDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setCtxDrag(false);
|
||||||
|
if (!view || e.dataTransfer.files.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles([...e.dataTransfer.files]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||||
<button
|
<button
|
||||||
@@ -842,12 +883,10 @@ function HomeBody({
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={async (e) => {
|
onChange={(e) => {
|
||||||
const picked = [...(e.target.files ?? [])];
|
const picked = [...(e.target.files ?? [])];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
for (const f of picked) {
|
void addFiles(picked);
|
||||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function WorkspaceRow({
|
|||||||
>
|
>
|
||||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||||
* which never has a session) as **Terminal** — and under the sessions the last
|
* which never has a session) as **Terminal** — and under the sessions the last
|
||||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||||
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
|
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
||||||
|
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
||||||
* it in a split beside the focused pane) — attaching, never starting or
|
* it in a split beside the focused pane) — attaching, never starting or
|
||||||
* resuming anything;
|
* resuming anything;
|
||||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
||||||
@@ -42,7 +43,8 @@ import { timeAgo } from "../../lib/format";
|
|||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { useContextMenu } from "../ContextMenu";
|
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
|
||||||
export const SESSION_LIMIT = 6;
|
export const SESSION_LIMIT = 6;
|
||||||
@@ -65,9 +67,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
|||||||
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
||||||
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
||||||
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||||
const strip = (p: string) => p.replace(/\/+$/, "");
|
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
|
||||||
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
||||||
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
|
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||||
@@ -265,10 +268,16 @@ export function WorkspaceSessionList({
|
|||||||
);
|
);
|
||||||
// The lane's own session/status/needs-you move on every hook, which is when
|
// The lane's own session/status/needs-you move on every hook, which is when
|
||||||
// a session can appear or change state, and a tab opening or getting its
|
// a session can appear or change state, and a tab opening or getting its
|
||||||
// session is too — re-read then, not on a timer.
|
// session is too — re-read then, not on a timer. Throttled to one re-read per
|
||||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
// 3 s: two sessions in one workspace flip these on every hook. A rename or an
|
||||||
|
// archive (`version`) re-reads at once.
|
||||||
|
const hookKey = useThrottledValue(
|
||||||
|
`${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns
|
||||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||||
.join(",")}`;
|
.join(",")}`,
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
const refreshKey = `${hookKey}:${version}`;
|
||||||
// A Terminal tab closing is when one joins the history.
|
// A Terminal tab closing is when one joins the history.
|
||||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
const shellKey = liveShells.map((r) => r.id).join(",");
|
||||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
||||||
@@ -297,6 +306,15 @@ export function WorkspaceSessionList({
|
|||||||
toast.error(err);
|
toast.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const closedCtx = useContextMenu<RunHistoryItem>();
|
||||||
|
const forgetShell = async (r: RunHistoryItem) => {
|
||||||
|
try {
|
||||||
|
await api.run.forgetHistory(r.id);
|
||||||
|
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||||
@@ -427,7 +445,7 @@ export function WorkspaceSessionList({
|
|||||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||||
{failure}
|
{failure}
|
||||||
{confirmArchive}
|
{confirmArchive}
|
||||||
</>
|
</>
|
||||||
@@ -435,7 +453,11 @@ export function WorkspaceSessionList({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
<ul
|
||||||
|
data-testid={`workspace-sessions-${lane.id}`}
|
||||||
|
// A guide line under the workspace's chevron; the rows start past it.
|
||||||
|
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
|
||||||
|
>
|
||||||
{fresh.map((r) => (
|
{fresh.map((r) => (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<button
|
<button
|
||||||
@@ -450,7 +472,7 @@ export function WorkspaceSessionList({
|
|||||||
}
|
}
|
||||||
onContextMenu={(e) => ctx.open(e, r)}
|
onContextMenu={(e) => ctx.open(e, r)}
|
||||||
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||||
r.id === selectedRunId
|
r.id === selectedRunId
|
||||||
? "bg-accent/10 text-fg-primary"
|
? "bg-accent/10 text-fg-primary"
|
||||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
@@ -458,19 +480,19 @@ export function WorkspaceSessionList({
|
|||||||
>
|
>
|
||||||
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||||
<span className="sr-only">{t("tree.state.live")}</span>
|
<span className="sr-only">{t("tree.state.live")}</span>
|
||||||
|
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
|
||||||
|
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||||
|
{r.startedAt ? timeAgo(r.startedAt, true) : null}
|
||||||
|
</span>
|
||||||
{r.tool === "shell" ? (
|
{r.tool === "shell" ? (
|
||||||
<>
|
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||||
<span className="truncate">{t("tabs.terminal")}</span>
|
|
||||||
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
|
||||||
<AiToolIcon
|
<AiToolIcon
|
||||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -480,7 +502,7 @@ export function WorkspaceSessionList({
|
|||||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||||
if (renaming === s.id) {
|
if (renaming === s.id) {
|
||||||
return (
|
return (
|
||||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
<li key={s.id} className="py-0.5 pr-2">
|
||||||
<input
|
<input
|
||||||
data-testid={`workspace-session-rename-${s.id}`}
|
data-testid={`workspace-session-rename-${s.id}`}
|
||||||
aria-label={t("tree.renameSession")}
|
aria-label={t("tree.renameSession")}
|
||||||
@@ -534,7 +556,7 @@ export function WorkspaceSessionList({
|
|||||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||||
: ""
|
: ""
|
||||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||||
selected
|
selected
|
||||||
? "bg-accent/10 text-fg-primary"
|
? "bg-accent/10 text-fg-primary"
|
||||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
@@ -554,9 +576,12 @@ export function WorkspaceSessionList({
|
|||||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||||
|
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||||
|
</span>
|
||||||
<AiToolIcon
|
<AiToolIcon
|
||||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -575,13 +600,14 @@ export function WorkspaceSessionList({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{closed.map((r) => (
|
{closed.map((r) => (
|
||||||
<li key={r.id}>
|
<li key={r.id} className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
data-testid={`workspace-terminal-closed-${r.id}`}
|
||||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||||
|
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||||
title={t("tree.closedTerminal")}
|
title={t("tree.closedTerminal")}
|
||||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -590,9 +616,22 @@ export function WorkspaceSessionList({
|
|||||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||||
<span className="truncate">{t("tabs.terminal")}</span>
|
<span className="truncate">{t("tabs.terminal")}</span>
|
||||||
<span className="ml-auto shrink-0 text-[11px]">
|
<span className="ml-auto shrink-0 text-[11px]">
|
||||||
{timeAgo(r.ended_at ?? r.started_at)}
|
{timeAgo(r.ended_at ?? r.started_at, true)}
|
||||||
</span>
|
</span>
|
||||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
<SquareTerminal
|
||||||
|
aria-hidden
|
||||||
|
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`workspace-terminal-forget-${r.id}`}
|
||||||
|
onClick={() => void forgetShell(r)}
|
||||||
|
title={t("tree.forgetTerminal")}
|
||||||
|
aria-label={t("tree.forgetTerminal")}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -602,7 +641,7 @@ export function WorkspaceSessionList({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||||
onClick={() => own && setLimit(own.total)}
|
onClick={() => own && setLimit(own.total)}
|
||||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{t("tree.seeAll", { count: more })}
|
{t("tree.seeAll", { count: more })}
|
||||||
</button>
|
</button>
|
||||||
@@ -619,6 +658,42 @@ export function WorkspaceSessionList({
|
|||||||
onClose={ctx.close}
|
onClose={ctx.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{closedCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={closedCtx.menu.x}
|
||||||
|
y={closedCtx.menu.y}
|
||||||
|
onClose={closedCtx.close}
|
||||||
|
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
closedCtx.close();
|
||||||
|
void reopenShell(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.reopenTerminal")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
closedCtx.close();
|
||||||
|
void reopenShell(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("sessionMenu.openSplit")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onSelect={() => {
|
||||||
|
const item = closedCtx.menu!.item;
|
||||||
|
closedCtx.close();
|
||||||
|
void forgetShell(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.forgetTerminal")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
{sessionCtx.menu && (
|
{sessionCtx.menu && (
|
||||||
<SessionMenu
|
<SessionMenu
|
||||||
x={sessionCtx.menu.x}
|
x={sessionCtx.menu.x}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn() },
|
aiProxies: { list: vi.fn() },
|
||||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
run: { start: vi.fn() },
|
run: { start: vi.fn() },
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
prompt: { mode: "argv" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
const TOOLS: AiToolsView = {
|
const TOOLS: AiToolsView = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
|
||||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("grok", "Grok", { enabled: false }),
|
||||||
@@ -158,7 +158,7 @@ beforeEach(() => {
|
|||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||||
vi.mocked(api.aiRouters.list).mockReset();
|
vi.mocked(api.aiProxies.list).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||||
@@ -639,12 +639,12 @@ describe("AddLaneModal — create/update readme", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — no router here", () => {
|
describe("AddLaneModal — no proxy here", () => {
|
||||||
it("asks for no AI Router: a new session picks its own", async () => {
|
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||||
renderModal({});
|
renderModal({});
|
||||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||||
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||||
expect(api.aiRouters.list).not.toHaveBeenCalled();
|
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||||
|
* count as edited in place (blue) rather than added or removed whole, and the
|
||||||
|
* editor's own diff classes on a real mount.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||||
|
|
||||||
|
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||||
|
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||||
|
Range.prototype.getClientRects ??= () => EMPTY;
|
||||||
|
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||||
|
|
||||||
|
describe("keepsText", () => {
|
||||||
|
it("is false for a line whose every non-blank character changed", () => {
|
||||||
|
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||||
|
expect(keepsText("", 0, [])).toBe(false);
|
||||||
|
// Only a `;` survived: not the same line.
|
||||||
|
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when some text outside the changes survives", () => {
|
||||||
|
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DiffEditor", () => {
|
||||||
|
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
value={"keep\nconst x = 2;\nbrand new"}
|
||||||
|
original={"keep\nconst x = 1;"}
|
||||||
|
lang="ts"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||||
|
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||||
|
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
value={"keep\nconst x = 2;"}
|
||||||
|
original={"keep\nconst x = 1;"}
|
||||||
|
lang="ts"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||||
|
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||||
|
expect(left).toHaveAttribute("contenteditable", "false");
|
||||||
|
expect(right).toHaveAttribute("contenteditable", "true");
|
||||||
|
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||||
|
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||||
|
// The revert column is drawn in CodeMirror's next measure pass, not at mount.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||||
|
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
collapse
|
||||||
|
value={`${same}\nchanged`}
|
||||||
|
original={`${same}\noriginal`}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||||
|
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||||
|
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
locks: {
|
locks: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: {
|
aiProxies: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
@@ -45,10 +45,10 @@ beforeEach(() => {
|
|||||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
// 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).mockReset();
|
||||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
// Only the built-in profile by default: a user who never opened the AI Router
|
// Only the built-in profile by default: a user who never opened the AI Proxy
|
||||||
// tab has nothing to switch between, so the row stays out of every other test.
|
// tab has nothing to switch between, so the row stays out of every other test.
|
||||||
vi.mocked(api.aiRouters.list).mockReset();
|
vi.mocked(api.aiProxies.list).mockReset();
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
|
||||||
});
|
});
|
||||||
vi.mocked(api.locks.list).mockReset();
|
vi.mocked(api.locks.list).mockReset();
|
||||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||||
|
|||||||
@@ -13,13 +13,42 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { useState } from "react";
|
|
||||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||||
import { confirmDiscard } from "../../../lib/confirm";
|
import { confirmDiscard } from "../../../lib/confirm";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import type { LaneChanges } from "../../../lib/types";
|
import type { LaneChanges } from "../../../lib/types";
|
||||||
|
|
||||||
// The themed confirm needs its host (App); answer it here.
|
// The themed confirm needs its host (App); answer it here.
|
||||||
|
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||||
|
vi.mock("../DiffEditor", () => ({
|
||||||
|
default: (p: {
|
||||||
|
value: string;
|
||||||
|
original: string;
|
||||||
|
layout?: string;
|
||||||
|
collapse?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) => (
|
||||||
|
<textarea
|
||||||
|
data-testid="lane-file-editor"
|
||||||
|
data-original={p.original}
|
||||||
|
data-layout={p.layout}
|
||||||
|
data-collapse={String(!!p.collapse)}
|
||||||
|
aria-label={p.label}
|
||||||
|
autoFocus
|
||||||
|
value={p.value}
|
||||||
|
onChange={(e) => p.onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
p.onSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../lib/confirm", () => ({
|
vi.mock("../../../lib/confirm", () => ({
|
||||||
confirmDiscard: vi.fn(async () => true),
|
confirmDiscard: vi.fn(async () => true),
|
||||||
confirmDialog: vi.fn(async () => true),
|
confirmDialog: vi.fn(async () => true),
|
||||||
@@ -32,6 +61,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
gitBranches: vi.fn(),
|
gitBranches: vi.fn(),
|
||||||
changes: vi.fn(),
|
changes: vi.fn(),
|
||||||
gitAction: vi.fn(),
|
gitAction: vi.fn(),
|
||||||
|
suggestCommitMessage: vi.fn(),
|
||||||
gitLog: vi.fn(),
|
gitLog: vi.fn(),
|
||||||
gitFile: vi.fn(),
|
gitFile: vi.fn(),
|
||||||
gitRawUrl: (id: number, path: string, rev: string) =>
|
gitRawUrl: (id: number, path: string, rev: string) =>
|
||||||
@@ -90,14 +120,20 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||||
|
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
/** The overview row's discard button for one path. */
|
/** The overview row's discard button for one path. */
|
||||||
function discardOf(path: string) {
|
function discardOf(path: string) {
|
||||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||||
name: "Discard this file's changes",
|
name: "Discard this file's changes",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||||
vi.mocked(api.lanes.changes).mockReset();
|
vi.mocked(api.lanes.changes).mockReset();
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||||
@@ -113,6 +149,7 @@ beforeEach(() => {
|
|||||||
author: "t",
|
author: "t",
|
||||||
date: "2026-09-22T01:00:00Z",
|
date: "2026-09-22T01:00:00Z",
|
||||||
refs: ["HEAD -> feat/thing"],
|
refs: ["HEAD -> feat/thing"],
|
||||||
|
parents: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -140,8 +177,8 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
|
/** The panel as the Workspace renders it: Files and Changes are its own mode
|
||||||
* panel — the tab is the Workspace's state, passed in as `tab`. */
|
* buttons now, no activity-bar tab around it. */
|
||||||
function Tabbed({
|
function Tabbed({
|
||||||
laneId,
|
laneId,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
@@ -149,18 +186,7 @@ function Tabbed({
|
|||||||
laneId: number;
|
laneId: number;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<"files" | "changes">("changes");
|
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button type="button" onClick={() => setTab("files")}>
|
|
||||||
Files
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setTab("changes")}>
|
|
||||||
Changes
|
|
||||||
</button>
|
|
||||||
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("LaneChangesPanel", () => {
|
describe("LaneChangesPanel", () => {
|
||||||
@@ -169,7 +195,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
||||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
||||||
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||||
expect(screen.getByText("+12")).toBeInTheDocument();
|
expect(screen.getByText("+12")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/new line/)).toBeNull();
|
expect(screen.queryByText(/new line/)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -194,7 +220,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||||
@@ -203,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
context: 3,
|
context: 3,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
await toDiff(user);
|
||||||
// The highlighter splits a line into one span per token, so assert on the
|
// The highlighter splits a line into one span per token, so assert on the
|
||||||
// rendered text of the whole view rather than on a single node.
|
// rendered text of the whole view rather than on a single node.
|
||||||
const view = await screen.findByTestId("lane-diff-view");
|
const view = await screen.findByTestId("lane-diff-view");
|
||||||
@@ -258,6 +285,65 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||||
|
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||||
|
"server/lib/git-ops.js",
|
||||||
|
]);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||||
|
// And back: the row's button now offers the way out.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||||
|
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws a never-stage file orange in the Files tree", async () => {
|
||||||
|
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||||
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: "",
|
||||||
|
entries: [
|
||||||
|
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||||
|
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Tabbed laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||||
|
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||||
|
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||||
|
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||||
|
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||||
|
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
|
// `server/lib` holds nothing but `lib`: one compacted row.
|
||||||
|
const folder = within(list).getByTitle("server/lib");
|
||||||
|
expect(folder).toHaveTextContent("1 file");
|
||||||
|
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
||||||
|
await user.click(folder);
|
||||||
|
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||||
|
await user.click(
|
||||||
|
within(folder.parentElement!).getByRole("button", {
|
||||||
|
name: "Stage every file in this folder",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||||
|
paths: ["server/lib/git-ops.js"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue({
|
vi.mocked(api.lanes.git).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
@@ -341,16 +427,26 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fills the commit box with the AI-suggested message", async () => {
|
||||||
|
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
||||||
|
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
||||||
|
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,12 +470,12 @@ describe("LaneChangesPanel", () => {
|
|||||||
render(<LaneChangesPanel laneId={7} full />);
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||||
await user.click(await within(sidebar).findByText("README.md"));
|
await user.click(await within(sidebar).findByText("README.md"));
|
||||||
await screen.findByTestId("lane-file-view");
|
await screen.findByTestId("lane-file-editor");
|
||||||
await user.click(screen.getByTestId("lane-file-preview"));
|
await user.click(screen.getByTestId("lane-file-preview"));
|
||||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||||
await user.click(screen.getByTestId("lane-file-preview"));
|
await user.click(screen.getByTestId("lane-file-preview"));
|
||||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an image file as the picture, with no edit control", async () => {
|
it("shows an image file as the picture, with no edit control", async () => {
|
||||||
@@ -408,7 +504,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
path: "",
|
path: "",
|
||||||
@@ -433,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||||
await user.click(await within(sidebar).findByText("main.ts"));
|
await user.click(await within(sidebar).findByText("main.ts"));
|
||||||
|
|
||||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
// Opening a file is editing it, diffed against its HEAD text.
|
||||||
const view = await screen.findByTestId("lane-file-view");
|
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
|
||||||
await user.click(view);
|
|
||||||
const editor = await screen.findByTestId("lane-file-editor");
|
const editor = await screen.findByTestId("lane-file-editor");
|
||||||
expect(editor).toHaveValue("const whole = 1;");
|
expect(editor).toHaveValue("const whole = 1;");
|
||||||
expect(editor).toHaveFocus();
|
expect(editor).toHaveFocus();
|
||||||
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||||
|
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||||
|
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||||
|
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||||
await user.type(editor, "x");
|
await user.type(editor, "x");
|
||||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||||
// …and "discard" drops it and goes back to reading.
|
// …and "discard" reverts it, still editing.
|
||||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
path: "server/lib/git-ops.js",
|
path: "server/lib/git-ops.js",
|
||||||
@@ -468,32 +564,41 @@ describe("LaneChangesPanel", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} full />);
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
// Changes is the editor folded to its changes, side by side at full width.
|
||||||
|
const editor = await screen.findByTestId("lane-file-editor");
|
||||||
|
expect(editor).toHaveValue("a");
|
||||||
|
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||||
|
expect(editor).toHaveAttribute("data-layout", "split");
|
||||||
|
await user.type(editor, "b");
|
||||||
|
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||||
|
vi.mocked(confirmDiscard).mockClear();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||||
|
const whole = await screen.findByTestId("lane-file-editor");
|
||||||
|
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||||
|
expect(whole).toHaveValue("ab");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||||
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||||
|
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
it("opens diffs unified at the narrow width", async () => {
|
||||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
path: "server/lib/git-ops.js",
|
path: "server/lib/git-ops.js",
|
||||||
rev: "work",
|
rev: "work",
|
||||||
binary: false,
|
binary: false,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
lines: ["copy me"],
|
lines: ["a"],
|
||||||
sha: "b".repeat(40),
|
sha: "b".repeat(40),
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} full />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||||
const view = await screen.findByTestId("lane-file-view");
|
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
|
||||||
toString: () => "copy",
|
|
||||||
} as unknown as Selection);
|
|
||||||
await user.click(view);
|
|
||||||
spy.mockRestore();
|
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||||
@@ -517,7 +622,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
const row = within(list).getByText("server/lib/git-ops.js");
|
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
|
||||||
await right();
|
await right();
|
||||||
@@ -577,9 +682,9 @@ describe("LaneChangesPanel", () => {
|
|||||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||||
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
|
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
await screen.findByText("server/lib/git-ops.js");
|
await screen.findByTitle("server/lib/git-ops.js");
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
@@ -612,7 +717,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
resolveOld(changes());
|
resolveOld(changes());
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens a commit from history as a commit-range review", async () => {
|
it("opens a commit from history as a commit-range review", async () => {
|
||||||
@@ -621,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "History" }));
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||||
await user.click(await screen.findByText("feat: the commit"));
|
await user.click(await screen.findByText("feat: the commit"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -766,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||||
await user.click(await screen.findByText("src/main.ts"));
|
await user.click(await screen.findByText("src/main.ts"));
|
||||||
await user.click(await screen.findByTestId("lane-file-view"));
|
|
||||||
const editor = await screen.findByTestId("lane-file-editor");
|
const editor = await screen.findByTestId("lane-file-editor");
|
||||||
expect(editor).toHaveValue("const whole = 1;");
|
expect(editor).toHaveValue("const whole = 1;");
|
||||||
await user.clear(editor);
|
await user.clear(editor);
|
||||||
@@ -779,36 +884,9 @@ describe("LaneChangesPanel", () => {
|
|||||||
"const whole = 2;",
|
"const whole = 2;",
|
||||||
"b".repeat(40)
|
"b".repeat(40)
|
||||||
);
|
);
|
||||||
// Back to reading, showing what was saved.
|
// Still editing, on what was saved: nothing left to save.
|
||||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||||
});
|
|
||||||
|
|
||||||
// The Workspace asked before switching tab; the edit must then be GONE, or
|
|
||||||
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
|
|
||||||
it("drops an edited file when the tab switches away from it", async () => {
|
|
||||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
||||||
available: true,
|
|
||||||
path: "src/main.ts",
|
|
||||||
rev: "work",
|
|
||||||
binary: false,
|
|
||||||
truncated: false,
|
|
||||||
lines: ["const whole = 1;"],
|
|
||||||
sha: "b".repeat(40),
|
|
||||||
});
|
|
||||||
const dirty = vi.fn();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
|
|
||||||
await screen.findByTestId("lane-changes-overview");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
||||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
||||||
await user.click(await screen.findByText("src/main.ts"));
|
|
||||||
await user.click(await screen.findByTestId("lane-file-view"));
|
|
||||||
await user.type(await screen.findByTestId("lane-file-editor"), "x");
|
|
||||||
expect(dirty).toHaveBeenLastCalledWith(true);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
|
||||||
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
|
|
||||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||||
@@ -852,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||||
await user.click(await screen.findByText("src/main.ts"));
|
await user.click(await screen.findByText("src/main.ts"));
|
||||||
await user.click(await screen.findByTestId("lane-file-view"));
|
|
||||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||||
await user.click(screen.getByTestId("lane-file-save"));
|
await user.click(screen.getByTestId("lane-file-save"));
|
||||||
|
|
||||||
@@ -892,7 +969,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||||
|
await toDiff(user);
|
||||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||||
@@ -903,7 +981,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||||
|
await toDiff(user);
|
||||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||||
|
|
||||||
// Collapse is dead at the tightest step, and live once expanded — an
|
// Collapse is dead at the tightest step, and live once expanded — an
|
||||||
@@ -933,8 +1012,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
await toDiff(user);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -995,11 +1074,10 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
|
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
for (const name of ["Changes", "Files", "Review", "History"]) {
|
||||||
for (const name of ["Overview", "Review", "History"]) {
|
|
||||||
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1351,9 +1429,9 @@ describe("LaneChangesPanel", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-commit");
|
await screen.findByTestId("lane-changes-commit");
|
||||||
const before = screen.getByText("server/lib/git-ops.js");
|
const before = screen.getByTitle("server/lib/git-ops.js");
|
||||||
await user.type(screen.getByLabelText("Commit message"), "x");
|
await user.type(screen.getByLabelText("Commit message"), "x");
|
||||||
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
|
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands all folders with ONE request, not one per folder", async () => {
|
it("expands all folders with ONE request, not one per folder", async () => {
|
||||||
@@ -1418,7 +1496,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
lane7Reads
|
lane7Reads
|
||||||
);
|
);
|
||||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
|||||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||||
|
// Two lines removed, none added: the right column's two rows are fillers.
|
||||||
|
const file = fileWith([
|
||||||
|
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||||
|
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||||
|
]);
|
||||||
|
render(<LaneDiffView file={file} layout="split" />);
|
||||||
|
const fillers = [
|
||||||
|
...screen
|
||||||
|
.getByTestId("lane-diff-split-right")
|
||||||
|
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||||
|
];
|
||||||
|
expect(fillers).toHaveLength(2);
|
||||||
|
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||||
|
});
|
||||||
|
|
||||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
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
|
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LaneDiffView — row styling", () => {
|
describe("LaneDiffView — row styling", () => {
|
||||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
// A line added or removed whole: nothing in common with its pair.
|
||||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
const WHOLE = fileWith([
|
||||||
const add = rowOf(container, "x = 2");
|
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||||
const del = rowOf(container, "x = 1");
|
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||||
|
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||||
|
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||||
|
const add = rowOf(container, "xyz");
|
||||||
|
const del = rowOf(container, "abc");
|
||||||
const ctx = rowOf(container, "keep()");
|
const ctx = rowOf(container, "keep()");
|
||||||
expect(add.className).toContain("bg-status-success/20");
|
expect(add.className).toContain("bg-status-success/20");
|
||||||
expect(add.className).toContain("--status-success");
|
expect(add.className).toContain("--status-success");
|
||||||
expect(del.className).toContain("bg-status-danger/20");
|
expect(del.className).toContain("bg-status-danger/20");
|
||||||
expect(del.className).toContain("--status-danger");
|
expect(del.className).toContain("--status-danger");
|
||||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tints a line edited in place blue on both sides", () => {
|
||||||
|
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||||
|
for (const text of ["x = 1", "x = 2"]) {
|
||||||
|
const row = rowOf(container, text);
|
||||||
|
expect(row.className).toContain("bg-diff-mod/20");
|
||||||
|
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||||
const marker = (row: HTMLElement) =>
|
const marker = (row: HTMLElement) =>
|
||||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||||
const plus = marker(rowOf(container, "x = 2"));
|
const plus = marker(rowOf(container, "xyz"));
|
||||||
const minus = marker(rowOf(container, "x = 1"));
|
const minus = marker(rowOf(container, "abc"));
|
||||||
const ctx = marker(rowOf(container, "keep()"));
|
const ctx = marker(rowOf(container, "keep()"));
|
||||||
expect(plus.textContent).toBe("+");
|
expect(plus.textContent).toBe("+");
|
||||||
expect(plus.className).toContain("font-bold");
|
expect(plus.className).toContain("font-bold");
|
||||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
|||||||
|
|
||||||
it("emphasises only the changed span of a paired del/add", () => {
|
it("emphasises only the changed span of a paired del/add", () => {
|
||||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||||
(e) => e.textContent
|
(e) => e.textContent
|
||||||
);
|
);
|
||||||
expect(emph).toEqual(["1", "2"]);
|
expect(emph).toEqual(["1", "2"]);
|
||||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
|||||||
layout="unified"
|
layout="unified"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
|
|||||||
behind: null,
|
behind: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
it("renders the live branch, head and subject", async () => {
|
||||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||||
render(<Harness laneId={1} />);
|
render(<Harness laneId={1} />);
|
||||||
|
|
||||||
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
|
|||||||
expect(block.textContent).toContain("feat/rename-metric");
|
expect(block.textContent).toContain("feat/rename-metric");
|
||||||
expect(block.textContent).toContain("9b3e74a");
|
expect(block.textContent).toContain("9b3e74a");
|
||||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
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 () => {
|
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||||
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
|
|||||||
render(<LaneGitRailCounts laneId={1} />);
|
render(<LaneGitRailCounts laneId={1} />);
|
||||||
|
|
||||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||||
|
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||||
|
const onShowAll = vi.fn();
|
||||||
|
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||||
|
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||||
|
expect(onShowAll).toHaveBeenCalledOnce();
|
||||||
|
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||||
render(<Harness laneId={1} />);
|
render(<Harness laneId={1} />);
|
||||||
const counts = await screen.findByTestId("lane-git-counts");
|
await screen.findByTestId("lane-git");
|
||||||
fireEvent.mouseEnter(counts);
|
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||||||
fireEvent.focus(counts);
|
|
||||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws the rail's counts vertical", async () => {
|
it("draws the rail's counts vertical", async () => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* @file WorkspaceHome.test.tsx
|
* @file WorkspaceHome.test.tsx
|
||||||
* @description The workspace home: a new session starts in the first free
|
* @description The workspace home: a new session starts in the first free
|
||||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||||
* default), AI and Model in the box and AI Router / Permission / Worktree
|
* default), AI and Model in the box and AI Proxy / Permission / Worktree
|
||||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
|
||||||
* transcript (never a resume), instructions save, context files upload and
|
* transcript (never a resume), instructions save, context files upload and
|
||||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import type { Lane, Session } from "../../../lib/types";
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
|
|||||||
it("picks the first slot no live run of this lane holds", () => {
|
it("picks the first slot no live run of this lane holds", () => {
|
||||||
expect(freeSlot(1, [])).toBe(0);
|
expect(freeSlot(1, [])).toBe(0);
|
||||||
expect(freeSlot(1, runs)).toBe(1);
|
expect(freeSlot(1, runs)).toBe(1);
|
||||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
|
||||||
|
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
|
||||||
|
);
|
||||||
expect(freeSlot(1, all)).toBeNull();
|
expect(freeSlot(1, all)).toBeNull();
|
||||||
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
|
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
|
||||||
expect(freeSlot(2, all)).toBe(0);
|
expect(freeSlot(2, all)).toBe(0);
|
||||||
@@ -222,7 +224,24 @@ describe("WorkspaceHome", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const first = renderHome();
|
||||||
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
|
||||||
|
first.unmount();
|
||||||
|
const other = renderHome(lane({ id: 2 }));
|
||||||
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
|
||||||
|
other.unmount();
|
||||||
|
renderHome();
|
||||||
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
|
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
|
||||||
|
await user.click(screen.getByTestId("workspace-home-start"));
|
||||||
|
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
const labels = (id: string) =>
|
const labels = (id: string) =>
|
||||||
@@ -233,7 +252,7 @@ describe("WorkspaceHome", () => {
|
|||||||
].map((x) => x.getAttribute("aria-label"));
|
].map((x) => x.getAttribute("aria-label"));
|
||||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||||
expect(labels("workspace-home-options")).toEqual([
|
expect(labels("workspace-home-options")).toEqual([
|
||||||
"AI Router",
|
"AI Proxy",
|
||||||
"Permission mode",
|
"Permission mode",
|
||||||
expect.stringContaining("own git worktree"),
|
expect.stringContaining("own git worktree"),
|
||||||
]);
|
]);
|
||||||
@@ -244,8 +263,8 @@ describe("WorkspaceHome", () => {
|
|||||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
|
||||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
|
||||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||||
} as never);
|
} as never);
|
||||||
@@ -270,9 +289,9 @@ describe("WorkspaceHome", () => {
|
|||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the router's models on a router, and sends the router", async () => {
|
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
|
||||||
localStorage.setItem("agenthub.session.router", "glm");
|
localStorage.setItem("agenthub.session.proxy", "glm");
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||||
{
|
{
|
||||||
@@ -297,7 +316,7 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
"start",
|
"start",
|
||||||
expect.objectContaining({ router: "glm" })
|
expect.objectContaining({ proxy: "glm" })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,6 +454,13 @@ describe("WorkspaceHome", () => {
|
|||||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||||
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
||||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
||||||
|
const a = new File(["a"], "a.csv", { type: "text/csv" });
|
||||||
|
const b = new File(["b"], "b.log", { type: "" });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
|
||||||
|
dataTransfer: { types: ["Files"], files: [a, b] },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
|
||||||
|
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
|
||||||
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
||||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests that a session finds its workspace and its `claude --worktree`
|
||||||
|
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
|
||||||
|
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
|
||||||
|
* on "/" only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
|
import { owningLane } from "../SessionResumeButton";
|
||||||
|
import { sessionWorktreeName } from "../WorkspaceSessionList";
|
||||||
|
|
||||||
|
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
|
||||||
|
|
||||||
|
describe("session paths", () => {
|
||||||
|
it("finds the deepest owning workspace with either separator", () => {
|
||||||
|
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
|
||||||
|
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
|
||||||
|
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
|
||||||
|
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
|
||||||
|
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a worktree name from a Windows or POSIX session folder", () => {
|
||||||
|
const at = (cwd: string) => ({ cwd }) as Session;
|
||||||
|
expect(
|
||||||
|
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
|
||||||
|
).toBe("fix-a");
|
||||||
|
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
|
||||||
|
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||||
* or an installed, enabled AI tool started bare on its default model, no
|
* or an installed, enabled AI tool started bare on its default model, no
|
||||||
* prompt (a prompt, router or worktree is still the workspace home's prompt
|
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||||
@@ -22,7 +22,10 @@
|
|||||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
* a `+` item) brings it into the tab on screen, split to the right, and a
|
||||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||||
* bar takes that pane out into a tab of its own there. A right-click on a tab
|
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||||
|
* of another tab, a tab (or a pane header) joins that tab instead
|
||||||
|
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||||
|
* one, up to four panes). A right-click on a tab
|
||||||
* opens the same actions as a menu (`ContextMenu`).
|
* opens the same actions as a menu (`ContextMenu`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -53,8 +56,10 @@ import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./Pan
|
|||||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||||
import {
|
import {
|
||||||
type PaneGroups,
|
type PaneGroups,
|
||||||
|
MAX_PANES,
|
||||||
detachSlot,
|
detachSlot,
|
||||||
groupOf,
|
groupOf,
|
||||||
|
joinGroup,
|
||||||
leaves,
|
leaves,
|
||||||
reorderGroups,
|
reorderGroups,
|
||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
@@ -207,6 +212,10 @@ export function ConsoleTabs({
|
|||||||
// point among the drawn tabs, from the pointer against their middles.
|
// point among the drawn tabs, from the pointer against their middles.
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||||
|
// The drawn tab a drop would join (the pointer on its middle half), and the
|
||||||
|
// slots a drag from this bar carries — unknown for a pane header (one slot).
|
||||||
|
const [joinAt, setJoinAt] = useState<number | null>(null);
|
||||||
|
const dragged = useRef<number[]>([]);
|
||||||
const isBarDrag = (e: DragEvent) => {
|
const isBarDrag = (e: DragEvent) => {
|
||||||
const types = e.dataTransfer?.types;
|
const types = e.dataTransfer?.types;
|
||||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||||
@@ -221,31 +230,70 @@ export function ConsoleTabs({
|
|||||||
x
|
x
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const joinIndexAt = (x: number) => {
|
||||||
|
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||||
|
const i = els.findIndex((el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
|
||||||
|
});
|
||||||
|
const tab = tabs[i];
|
||||||
|
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||||
|
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||||
|
};
|
||||||
|
const endDrag = () => {
|
||||||
|
dragged.current = [];
|
||||||
|
setDropAt(null);
|
||||||
|
setJoinAt(null);
|
||||||
|
};
|
||||||
const dragHandlers = {
|
const dragHandlers = {
|
||||||
onDragOver: (e: DragEvent) => {
|
onDragOver: (e: DragEvent) => {
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
const at = dropIndexAt(e.clientX);
|
const join = joinIndexAt(e.clientX);
|
||||||
|
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||||
|
setJoinAt((d) => (d === join ? d : join));
|
||||||
setDropAt((d) => (d === at ? d : at));
|
setDropAt((d) => (d === at ? d : at));
|
||||||
},
|
},
|
||||||
onDragLeave: (e: DragEvent) => {
|
onDragLeave: (e: DragEvent) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||||
},
|
|
||||||
onDragEnd: () => setDropAt(null),
|
|
||||||
onDrop: (e: DragEvent) => {
|
|
||||||
setDropAt(null);
|
setDropAt(null);
|
||||||
|
setJoinAt(null);
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
onDrop: (e: DragEvent) => {
|
||||||
|
const join = joinIndexAt(e.clientX);
|
||||||
|
endDrag();
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||||
|
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||||
|
const target = join === null ? undefined : tabs[join];
|
||||||
|
// A pane header dropped on its OWN tab is a detach, not a join: a pane
|
||||||
|
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
|
||||||
|
const ownTab =
|
||||||
|
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
|
||||||
|
if (target && !ownTab) {
|
||||||
|
onGroupsChange((g) =>
|
||||||
|
joinGroup(
|
||||||
|
g,
|
||||||
|
tab !== ""
|
||||||
|
? leaves(g.groups[Number(tab)]?.root ?? null)
|
||||||
|
: raw !== ""
|
||||||
|
? [Number(raw)]
|
||||||
|
: [],
|
||||||
|
target.index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The drawn position, as an index into the groups.
|
// The drawn position, as an index into the groups.
|
||||||
const drawn = dropIndexAt(e.clientX);
|
const drawn = dropIndexAt(e.clientX);
|
||||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
|
||||||
if (tab !== "") {
|
if (tab !== "") {
|
||||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
|
||||||
if (raw === "") return;
|
if (raw === "") return;
|
||||||
const slot = Number(raw);
|
const slot = Number(raw);
|
||||||
onGroupsChange((g) => {
|
onGroupsChange((g) => {
|
||||||
@@ -432,6 +480,7 @@ export function ConsoleTabs({
|
|||||||
data-slots={tab.key}
|
data-slots={tab.key}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
|
dragged.current = tab.slots;
|
||||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||||
else {
|
else {
|
||||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||||
@@ -445,7 +494,7 @@ export function ConsoleTabs({
|
|||||||
active
|
active
|
||||||
? "bg-surface-0 text-fg-primary"
|
? "bg-surface-0 text-fg-primary"
|
||||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
}`}
|
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
|
||||||
>
|
>
|
||||||
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
|
|||||||
return [current.groups, update];
|
return [current.groups, update];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Zone = Edge | "center";
|
export type Zone = Edge | "center";
|
||||||
const pct = (r: Rect) => ({
|
const pct = (r: Rect) => ({
|
||||||
left: `${r.x * 100}%`,
|
left: `${r.x * 100}%`,
|
||||||
top: `${r.y * 100}%`,
|
top: `${r.y * 100}%`,
|
||||||
width: `${r.w * 100}%`,
|
width: `${r.w * 100}%`,
|
||||||
height: `${r.h * 100}%`,
|
height: `${r.h * 100}%`,
|
||||||
});
|
});
|
||||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
||||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
* splits. The drop applies it and the preview draws it, so they agree. */
|
||||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
||||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
||||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
}
|
||||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
|
||||||
return r;
|
/** Where `src` would land, or null for a drop that changes nothing. A pane
|
||||||
|
* already shown leaves its place first, so its target grows before the
|
||||||
|
* split — the target's own half is not where it lands. */
|
||||||
|
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
|
||||||
|
if (src === slot) return null;
|
||||||
|
const next = applyDrop(l, src, slot, zone);
|
||||||
|
if (next === l) return null;
|
||||||
|
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||||
@@ -323,7 +330,7 @@ export function PaneGrid({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
|
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||||
const { panes, dividers } = layoutRects(layout.root);
|
const { panes, dividers } = layoutRects(layout.root);
|
||||||
const shown = leaves(layout.root);
|
const shown = leaves(layout.root);
|
||||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||||
@@ -391,6 +398,8 @@ export function PaneGrid({
|
|||||||
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
|
||||||
|
|
||||||
const isPaneDrag = (e: React.DragEvent) =>
|
const isPaneDrag = (e: React.DragEvent) =>
|
||||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||||
|
|
||||||
@@ -404,7 +413,10 @@ export function PaneGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
const z = zoneAt(e);
|
const z = zoneAt(e);
|
||||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||||
|
setDrop((d) =>
|
||||||
|
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onDragLeave={(e) => {
|
onDragLeave={(e) => {
|
||||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||||
@@ -417,11 +429,7 @@ export function PaneGrid({
|
|||||||
dragSlot = null;
|
dragSlot = null;
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||||
onChange((l) =>
|
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||||
z.zone === "center"
|
|
||||||
? swapInLayout(l, src, z.slot)
|
|
||||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mounted.map((slot) => {
|
{mounted.map((slot) => {
|
||||||
@@ -561,11 +569,11 @@ export function PaneGrid({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{drop && rectOf.has(drop.slot) && (
|
{preview && (
|
||||||
<div
|
<div
|
||||||
data-testid="pane-drop-preview"
|
data-testid="pane-drop-preview"
|
||||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||||
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
style={pct(preview)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @file ProxyPicker.tsx
|
||||||
|
* @description The AI Proxy a NEW session starts on — a session's choice, not
|
||||||
|
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
|
||||||
|
* Native or one of the saved profiles (`useAiProxies`, which the caller also
|
||||||
|
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||||
|
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||||
|
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||||
|
* `agenthub.session.router` is still read when that is unset).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type AiProxy } from "../../lib/api";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
|
||||||
|
const KEY = "agenthub.session.proxy";
|
||||||
|
const LEGACY_KEY = "agenthub.session.router";
|
||||||
|
|
||||||
|
/** The remembered pick, `"native"` by default. */
|
||||||
|
export function useSessionProxy(): [string, (v: string) => void] {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
|
||||||
|
} catch {
|
||||||
|
return "native";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const set = (v: string) => {
|
||||||
|
setValue(v);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, v);
|
||||||
|
} catch {
|
||||||
|
/* storage off — the pick still holds for this page */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved profiles (Native included), null until they arrive. */
|
||||||
|
export function useAiProxies(): AiProxy[] | null {
|
||||||
|
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.aiProxies
|
||||||
|
.list()
|
||||||
|
.then((r) => alive && setProxies(r.items))
|
||||||
|
.catch(() => alive && setProxies([]));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return proxies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
|
||||||
|
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
|
||||||
|
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProxyPicker({
|
||||||
|
tool,
|
||||||
|
proxies,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
variant?: "field" | "ghost";
|
||||||
|
tool: string;
|
||||||
|
/** From `useAiProxies`. */
|
||||||
|
proxies: AiProxy[] | null;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
|
||||||
|
const native = t("proxy.native", { tool: aiTools.label(tool) });
|
||||||
|
|
||||||
|
// A remembered profile that has since been deleted falls back to native,
|
||||||
|
// or the start would be refused for a proxy the user cannot see.
|
||||||
|
useEffect(() => {
|
||||||
|
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||||
|
}, [proxies, value, onChange]);
|
||||||
|
|
||||||
|
const options =
|
||||||
|
capable && proxies?.length
|
||||||
|
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||||
|
: [{ value: "native", label: native }];
|
||||||
|
return (
|
||||||
|
<div data-testid="proxy-picker">
|
||||||
|
<Select<string>
|
||||||
|
value={capable ? value : "native"}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
disabled={!capable}
|
||||||
|
variant={variant}
|
||||||
|
label={t("proxy.label")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file RouterPicker.tsx
|
|
||||||
* @description The AI Router a NEW session starts on — a session's choice, not
|
|
||||||
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
|
|
||||||
* Native or one of the saved profiles (`useAiRouters`, which the caller also
|
|
||||||
* reads to list the router's models). A tool that cannot use a router (only
|
|
||||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
|
||||||
* for the next session (`agenthub.session.router`).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { api, type AiRouter } from "../../lib/api";
|
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
|
||||||
import { Select } from "../Select";
|
|
||||||
|
|
||||||
const KEY = "agenthub.session.router";
|
|
||||||
|
|
||||||
/** The remembered pick, `"native"` by default. */
|
|
||||||
export function useSessionRouter(): [string, (v: string) => void] {
|
|
||||||
const [value, setValue] = useState(() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(KEY) || "native";
|
|
||||||
} catch {
|
|
||||||
return "native";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const set = (v: string) => {
|
|
||||||
setValue(v);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(KEY, v);
|
|
||||||
} catch {
|
|
||||||
/* storage off — the pick still holds for this page */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return [value, set];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The saved profiles (Native included), null until they arrive. */
|
|
||||||
export function useAiRouters(): AiRouter[] | null {
|
|
||||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
api.aiRouters
|
|
||||||
.list()
|
|
||||||
.then((r) => alive && setRouters(r.items))
|
|
||||||
.catch(() => alive && setRouters([]));
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return routers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
|
|
||||||
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
|
|
||||||
return routerCapable && router && router !== "native" ? { router } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RouterPicker({
|
|
||||||
tool,
|
|
||||||
routers,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
variant?: "field" | "ghost";
|
|
||||||
tool: string;
|
|
||||||
/** From `useAiRouters`. */
|
|
||||||
routers: AiRouter[] | null;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation(["lanes"]);
|
|
||||||
const aiTools = useAiTools();
|
|
||||||
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
|
||||||
const native = t("router.native", { tool: aiTools.label(tool) });
|
|
||||||
|
|
||||||
// A remembered profile that has since been deleted falls back to native,
|
|
||||||
// or the start would be refused for a router the user cannot see.
|
|
||||||
useEffect(() => {
|
|
||||||
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
|
|
||||||
}, [routers, value, onChange]);
|
|
||||||
|
|
||||||
const options =
|
|
||||||
capable && routers?.length
|
|
||||||
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
|
||||||
: [{ value: "native", label: native }];
|
|
||||||
return (
|
|
||||||
<div data-testid="router-picker">
|
|
||||||
<Select<string>
|
|
||||||
value={capable ? value : "native"}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
disabled={!capable}
|
|
||||||
variant={variant}
|
|
||||||
label={t("router.label")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@
|
|||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||||
* newlines, and a pasted or dropped image attached by its saved path
|
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||||
* (`POST /api/run/drop-image`);
|
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
* or a fixed one (an AI Router's models), plus Custom…;
|
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||||
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
||||||
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
||||||
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Home,
|
Home,
|
||||||
@@ -47,12 +48,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
source: "project" | "user" | "builtin";
|
source: "project" | "user" | "plugin" | "builtin";
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commands the CLI answers itself. They are typed into the pane like any
|
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||||
// other text, so they do run — they are just not files anyone can list.
|
// typed into the pane like any other text, so they do run — they are just not
|
||||||
|
// files anyone can list.
|
||||||
|
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
||||||
|
// API lists the built-ins, add names here as they ship.
|
||||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||||
"help",
|
"help",
|
||||||
"clear",
|
"clear",
|
||||||
@@ -62,6 +66,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
"memory",
|
"memory",
|
||||||
"hooks",
|
"hooks",
|
||||||
"cost",
|
"cost",
|
||||||
|
"usage",
|
||||||
|
"context",
|
||||||
"agents",
|
"agents",
|
||||||
"review",
|
"review",
|
||||||
"permissions",
|
"permissions",
|
||||||
@@ -69,14 +75,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
"init",
|
"init",
|
||||||
"mcp",
|
"mcp",
|
||||||
"plugin",
|
"plugin",
|
||||||
|
"reload-plugins",
|
||||||
|
"skills",
|
||||||
"output-style",
|
"output-style",
|
||||||
|
"resume",
|
||||||
|
"rewind",
|
||||||
|
"add-dir",
|
||||||
|
"doctor",
|
||||||
|
"export",
|
||||||
|
"login",
|
||||||
|
"logout",
|
||||||
|
"statusline",
|
||||||
|
"theme",
|
||||||
|
"fast",
|
||||||
"exit",
|
"exit",
|
||||||
|
"code-review",
|
||||||
|
"security-review",
|
||||||
|
"simplify",
|
||||||
|
"loop",
|
||||||
|
"schedule",
|
||||||
|
"fewer-permission-prompts",
|
||||||
|
"update-config",
|
||||||
|
"claude-api",
|
||||||
|
"run",
|
||||||
].map((name) => ({ name, source: "builtin" }));
|
].map((name) => ({ name, source: "builtin" }));
|
||||||
|
|
||||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
||||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||||
project: "text-status-success border-status-success/40",
|
project: "text-status-success border-status-success/40",
|
||||||
user: "text-accent border-accent/40",
|
user: "text-accent border-accent/40",
|
||||||
|
plugin: "text-accent border-accent/40",
|
||||||
builtin: "text-fg-muted border-border",
|
builtin: "text-fg-muted border-border",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,8 +154,8 @@ interface PromptEditorProps {
|
|||||||
slash: boolean;
|
slash: boolean;
|
||||||
/** Where `@` looks for files and project commands/skills are read from. */
|
/** Where `@` looks for files and project commands/skills are read from. */
|
||||||
fileCwd: string;
|
fileCwd: string;
|
||||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||||
* them with the text through `promptWithImages`. */
|
* sends them with the text through `promptWithImages`. */
|
||||||
images: PromptImage[];
|
images: PromptImage[];
|
||||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||||
/** Borderless, for a composer box that draws its own frame. */
|
/** Borderless, for a composer box that draws its own frame. */
|
||||||
@@ -137,11 +165,12 @@ interface PromptEditorProps {
|
|||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An attached image: its path on the server's machine and a local preview. */
|
/** An attached file: its path on the server's machine, and a local preview
|
||||||
export type PromptImage = { path: string; url: string; name: string };
|
* `url` when it is an image. */
|
||||||
|
export type PromptImage = { path: string; url?: string; name: string };
|
||||||
|
|
||||||
/** The prompt as sent: the text, then each image's saved path — which is how
|
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||||
* the CLI is handed an image. */
|
* the CLI is handed an image or a file. */
|
||||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
@@ -185,8 +214,26 @@ export function PromptEditor({
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
]).then(([c, sk]) => {
|
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
]).then(([c, sk, pl]) => {
|
||||||
|
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||||
|
// installed at both user and project scope is listed once.
|
||||||
|
const fromPlugins = pl.plugins
|
||||||
|
.filter((p) => p.enabled !== false && p.contributes)
|
||||||
|
.flatMap((p) =>
|
||||||
|
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
||||||
|
(it): SlashCommand => ({
|
||||||
|
name: `${p.name}:${it.name}`,
|
||||||
|
source: "plugin",
|
||||||
|
description: it.description || undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
||||||
|
if (live)
|
||||||
|
setCommands(
|
||||||
|
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -264,9 +311,9 @@ export function PromptEditor({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pasted or dropped image is saved on the server's machine (the same
|
// A pasted or dropped file is saved on the server's machine (the same
|
||||||
// route the terminal's drop uses) and shown as a thumbnail; its path is
|
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||||
// added to the prompt only when it is sent (promptWithImages).
|
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||||
@@ -275,15 +322,15 @@ export function PromptEditor({
|
|||||||
busyRef.current = onBusyChange;
|
busyRef.current = onBusyChange;
|
||||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||||
const attachImages = (list: FileList | null | undefined) => {
|
const attachImages = (list: FileList | null | undefined) => {
|
||||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
const picked = Array.from(list ?? []);
|
||||||
if (picked.length === 0) return false;
|
if (picked.length === 0) return false;
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||||
.then((saved) => {
|
.then((saved) => {
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
const added = picked.map((f, i) => ({
|
const added = picked.map((f, i) => ({
|
||||||
path: saved[i]!.path,
|
path: saved[i]!.path,
|
||||||
url: URL.createObjectURL(f),
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
onImagesChange((prev) => [...prev, ...added]);
|
onImagesChange((prev) => [...prev, ...added]);
|
||||||
@@ -292,7 +339,7 @@ export function PromptEditor({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const removeImage = (img: PromptImage) => {
|
const removeImage = (img: PromptImage) => {
|
||||||
URL.revokeObjectURL(img.url);
|
if (img.url) URL.revokeObjectURL(img.url);
|
||||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,7 +396,8 @@ export function PromptEditor({
|
|||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
<div key={img.url} className="group relative">
|
<div key={img.path} className="group relative">
|
||||||
|
{img.url ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={img.name}
|
aria-label={img.name}
|
||||||
@@ -360,6 +408,15 @@ export function PromptEditor({
|
|||||||
>
|
>
|
||||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title={img.path}
|
||||||
|
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="truncate">{img.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("drop.remove")}
|
aria-label={t("drop.remove")}
|
||||||
@@ -671,7 +728,7 @@ const MODEL_CUSTOM = "__custom__";
|
|||||||
/**
|
/**
|
||||||
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
||||||
* own list (Codex's models cache, Claude's aliases named after the newest
|
* own list (Codex's models cache, Claude's aliases named after the newest
|
||||||
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
|
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
|
||||||
* Claude's static aliases only when the server cannot answer. `loaded` once
|
* Claude's static aliases only when the server cannot answer. `loaded` once
|
||||||
* the list is final.
|
* the list is final.
|
||||||
*/
|
*/
|
||||||
@@ -707,7 +764,7 @@ export function ModelPicker({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||||
choices?: ModelChoice[];
|
choices?: ModelChoice[];
|
||||||
variant?: "field" | "ghost";
|
variant?: "field" | "ghost";
|
||||||
}) {
|
}) {
|
||||||
@@ -781,7 +838,7 @@ export function ModelMenu({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||||
choices?: ModelChoice[];
|
choices?: ModelChoice[];
|
||||||
/** Omitted for a tool that takes no effort. */
|
/** Omitted for a tool that takes no effort. */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
|||||||
@@ -9,11 +9,15 @@
|
|||||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
* 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
|
* 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.
|
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||||
|
* Retries back off from 1.5 s to 30 s (reset once a socket opens) and stop on
|
||||||
|
* close code 1008. xterm and its addons are loaded on the first terminal
|
||||||
|
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
* Files (images or any other) dropped on it are saved on the server
|
||||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* and how `claude` attaches an image. The pane also registers with
|
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||||
|
* a file. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
* pasted into whichever of the workspace's panes was focused last.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||||
@@ -32,12 +36,10 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
import type { SearchAddon } from "@xterm/addon-search";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
||||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
@@ -45,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
|||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
|
|
||||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
type DropState = "hint" | "uploading" | "failed";
|
||||||
|
|
||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -72,11 +74,49 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** xterm and its addons (~400 KB) load with the first terminal, not with the
|
||||||
|
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||||
|
* awaits `loadXterm()` first) sets up synchronously. */
|
||||||
|
const importXterm = async () => {
|
||||||
|
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-canvas"),
|
||||||
|
import("@xterm/addon-clipboard"),
|
||||||
|
import("@xterm/addon-search"),
|
||||||
|
import("@xterm/addon-web-links"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
Terminal: xterm.Terminal,
|
||||||
|
FitAddon: fit.FitAddon,
|
||||||
|
CanvasAddon: canvas.CanvasAddon,
|
||||||
|
SearchAddon: search.SearchAddon,
|
||||||
|
WebLinksAddon: links.WebLinksAddon,
|
||||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||||
const ClipboardWithProvider = ClipboardAddon as unknown as new (
|
ClipboardWithProvider: clipboard.ClipboardAddon as unknown as new (
|
||||||
base64: undefined,
|
base64: undefined,
|
||||||
provider: IClipboardProvider
|
provider: IClipboardProvider
|
||||||
) => ClipboardAddon;
|
) => ClipboardAddon,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type Xterm = Awaited<ReturnType<typeof importXterm>>;
|
||||||
|
let xtermLoaded: Xterm | null = null;
|
||||||
|
let xtermLoading: Promise<Xterm> | null = null;
|
||||||
|
export function loadXterm(): Promise<Xterm> {
|
||||||
|
xtermLoading ??= importXterm().then(
|
||||||
|
(x) => (xtermLoaded = x),
|
||||||
|
(err) => {
|
||||||
|
xtermLoading = null; // a failed chunk fetch may be retried by the next pane
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return xtermLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||||
|
const RETRY_MIN_MS = 1500;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
@@ -144,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (images.length === 0) return flash("imagesOnly");
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
try {
|
try {
|
||||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||||
// sees one pasted path rather than typed keystrokes.
|
// sees pasted paths rather than typed keystrokes.
|
||||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
@@ -160,6 +200,15 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const attach = ({
|
||||||
|
Terminal,
|
||||||
|
FitAddon,
|
||||||
|
CanvasAddon,
|
||||||
|
SearchAddon,
|
||||||
|
WebLinksAddon,
|
||||||
|
ClipboardWithProvider,
|
||||||
|
}: Xterm) => {
|
||||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||||
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
||||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||||
@@ -261,7 +310,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
const findChord =
|
const findChord =
|
||||||
!ev.altKey &&
|
!ev.altKey &&
|
||||||
key === "f" &&
|
key === "f" &&
|
||||||
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) ||
|
||||||
|
(ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
||||||
if (findChord) {
|
if (findChord) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setFind((f) => f ?? "");
|
setFind((f) => f ?? "");
|
||||||
@@ -307,7 +357,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
};
|
};
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let ended = false; // server said the pane exited — nothing left to attach to
|
let ended = false; // server said the pane exited — nothing left to attach to
|
||||||
let disposed = false;
|
let retryMs = RETRY_MIN_MS;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
// The attach itself carries this terminal's size. tmux sizes the window
|
// The attach itself carries this terminal's size. tmux sizes the window
|
||||||
@@ -317,7 +367,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
// columns first — and everything painted at that width stays in the
|
// columns first — and everything painted at that width stays in the
|
||||||
// scrollback as torn fragments along the left margin, visible as soon as
|
// scrollback as torn fragments along the left margin, visible as soon as
|
||||||
// you scroll back (issue #33). Happens on every attach: page load,
|
// you scroll back (issue #33). Happens on every attach: page load,
|
||||||
// lane switch, and each 1.5 s reconnect.
|
// lane switch, and each reconnect.
|
||||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||||
ws = sock;
|
ws = sock;
|
||||||
@@ -328,11 +378,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
sock.binaryType = "arraybuffer";
|
sock.binaryType = "arraybuffer";
|
||||||
|
|
||||||
sock.onopen = () => {
|
sock.onopen = () => {
|
||||||
|
retryMs = RETRY_MIN_MS;
|
||||||
setReconnecting(false);
|
setReconnecting(false);
|
||||||
sendSizeNow();
|
sendSizeNow();
|
||||||
};
|
};
|
||||||
sock.onmessage = (event) => {
|
sock.onmessage = (event) => {
|
||||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
const isArrayBuffer =
|
||||||
|
Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||||
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
||||||
// to the next decode() call when a PTY chunk splits mid-character —
|
// to the next decode() call when a PTY chunk splits mid-character —
|
||||||
// without it, a Vietnamese/accented char split across two WS frames
|
// without it, a Vietnamese/accented char split across two WS frames
|
||||||
@@ -358,13 +410,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
term.write(data);
|
term.write(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sock.onclose = () => {
|
sock.onclose = (e?: CloseEvent) => {
|
||||||
if (disposed || ended) return;
|
// 1008: the server refused this attach (policy) — retrying cannot help.
|
||||||
|
if (disposed || ended || e?.code === 1008) return;
|
||||||
// tmux keeps the session alive across a server restart, so re-attaching
|
// tmux keeps the session alive across a server restart, so re-attaching
|
||||||
// repaints the pane in full — no output is lost by retrying. Input is
|
// repaints the pane in full — no output is lost by retrying. Input is
|
||||||
// not: the overlay says so rather than letting keystrokes vanish.
|
// not: the overlay says so rather than letting keystrokes vanish.
|
||||||
|
// Backoff (1.5 s → 30 s, reset on open) so a server that stays down is
|
||||||
|
// not hammered by every open pane.
|
||||||
setReconnecting(true);
|
setReconnecting(true);
|
||||||
retryTimer = setTimeout(connect, 1500);
|
retryTimer = setTimeout(connect, retryMs);
|
||||||
|
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
connect();
|
connect();
|
||||||
@@ -406,7 +462,6 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
if (sizeTimer) clearTimeout(sizeTimer);
|
if (sizeTimer) clearTimeout(sizeTimer);
|
||||||
searchRef.current = null;
|
searchRef.current = null;
|
||||||
@@ -420,6 +475,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
// Synchronous once xterm is loaded; the first pane waits for the chunk, and
|
||||||
|
// `disposed` drops a setup that lands after this effect was cleaned up.
|
||||||
|
let teardown: (() => void) | undefined;
|
||||||
|
const start = (x: Xterm) => {
|
||||||
|
if (!disposed) teardown = attach(x);
|
||||||
|
};
|
||||||
|
if (xtermLoaded) start(xtermLoaded);
|
||||||
|
else loadXterm().then(start, () => undefined);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
teardown?.();
|
||||||
|
};
|
||||||
}, [runId, wsBaseUrl]);
|
}, [runId, wsBaseUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -583,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
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 ${
|
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"
|
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||||
? "border-red-400 text-red-300"
|
|
||||||
: "border-sky-400 text-sky-200"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`drop.${drop}`)}
|
{t(`drop.${drop}`)}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @file PaneGrid.test.ts
|
||||||
|
* @description The pane drop preview: it draws where the dragged pane will
|
||||||
|
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||||
|
* current box, and nothing for a drop that changes nothing.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dropPreview } from "../PaneGrid";
|
||||||
|
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||||
|
|
||||||
|
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||||
|
|
||||||
|
describe("dropPreview", () => {
|
||||||
|
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
|
||||||
|
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pane from another tab splits the target's current box", () => {
|
||||||
|
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the center swaps into the target's box", () => {
|
||||||
|
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing on its own pane or on a refused move", () => {
|
||||||
|
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
|
||||||
|
const full: PaneLayout = {
|
||||||
|
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
|
||||||
|
focus: 0,
|
||||||
|
};
|
||||||
|
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-28
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file RouterPicker.test.tsx
|
* @file ProxyPicker.test.tsx
|
||||||
* @description The per-session AI Router picker: Native plus the saved
|
* @description The per-session AI Proxy picker: Native plus the saved
|
||||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||||
* remembering the pick, and falling back to Native when the remembered
|
* remembering the pick, and falling back to Native when the remembered
|
||||||
* profile is gone; plus `routerArg`, which never sends native.
|
* profile is gone; plus `proxyArg`, which never sends native.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
import type { AiRouter } from "../../../lib/api";
|
import type { AiProxy } from "../../../lib/api";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn().mockResolvedValue({
|
list: vi.fn().mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
].map((x) => ({ ...x, installed: true, enabled: true })),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn() },
|
aiProxies: { list: vi.fn() },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.mocked(api.aiRouters.list)
|
vi.mocked(api.aiProxies.list)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [native, nine] } as never);
|
.mockResolvedValue({ items: [native, nine] } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RouterPicker", () => {
|
describe("ProxyPicker", () => {
|
||||||
it("lists the saved profiles for a tool that can use one", async () => {
|
it("lists the saved profiles for a tool that can use one", async () => {
|
||||||
render(
|
render(
|
||||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||||
);
|
);
|
||||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
|
||||||
render(
|
render(
|
||||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||||
);
|
);
|
||||||
const picker = screen.getByTestId("router-picker");
|
const picker = screen.getByTestId("proxy-picker");
|
||||||
expect(picker.querySelector("button")).toBeDisabled();
|
expect(picker.querySelector("button")).toBeDisabled();
|
||||||
expect(picker).not.toHaveTextContent("9Router");
|
expect(picker).not.toHaveTextContent("9Router");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to Native when the remembered profile is gone", async () => {
|
it("falls back to Native when the remembered profile is gone", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
|
||||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("useAiRouters reads the saved profiles once", async () => {
|
it("useAiProxies reads the saved profiles once", async () => {
|
||||||
const { result } = renderHook(() => useAiRouters());
|
const { result } = renderHook(() => useAiProxies());
|
||||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useSessionRouter / routerArg", () => {
|
describe("useSessionProxy / proxyArg", () => {
|
||||||
it("remembers the last pick for the next session", () => {
|
it("remembers the last pick for the next session", () => {
|
||||||
const first = renderHook(() => useSessionRouter());
|
const first = renderHook(() => useSessionProxy());
|
||||||
expect(first.result.current[0]).toBe("native");
|
expect(first.result.current[0]).toBe("native");
|
||||||
act(() => first.result.current[1]("9router"));
|
act(() => first.result.current[1]("9router"));
|
||||||
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
|
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a router only for a capable tool and never native", () => {
|
it("still reads the pick remembered before the rename", () => {
|
||||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
localStorage.setItem("agenthub.session.router", "9router");
|
||||||
expect(routerArg(true, "native")).toEqual({});
|
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||||
expect(routerArg(false, "9router")).toEqual({});
|
});
|
||||||
|
|
||||||
|
it("sends a proxy only for a capable tool and never native", () => {
|
||||||
|
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
|
||||||
|
expect(proxyArg(true, "native")).toEqual({});
|
||||||
|
expect(proxyArg(false, "9router")).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||||
* same list as one chip: aliases up front, pinned versions under More models,
|
* same list as one chip: aliases up front, pinned versions under More models,
|
||||||
* plus the effort slider.
|
* plus the effort slider.
|
||||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
items: [
|
items: [
|
||||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
skills: r({
|
skills: r({
|
||||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||||
}),
|
}),
|
||||||
|
plugins: r({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "vercel",
|
||||||
|
enabled: true,
|
||||||
|
contributes: {
|
||||||
|
items: {
|
||||||
|
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
|
||||||
|
commands: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "off",
|
||||||
|
enabled: false,
|
||||||
|
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
|
|||||||
expect(options.map((o) => o.textContent)).toEqual([
|
expect(options.map((o) => o.textContent)).toEqual([
|
||||||
expect.stringContaining("/review"),
|
expect.stringContaining("/review"),
|
||||||
expect.stringContaining("/review-pr"),
|
expect.stringContaining("/review-pr"),
|
||||||
|
// Claude Code's bundled skills, matched on a word of their name.
|
||||||
|
expect.stringContaining("/code-review"),
|
||||||
|
expect.stringContaining("/security-review"),
|
||||||
]);
|
]);
|
||||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||||
// Read for the directory the session will start in, not the server's own.
|
// Read for the directory the session will start in, not the server's own.
|
||||||
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
|
|||||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers enabled plugins' skills under their plugin's name", async () => {
|
||||||
|
renderEditor("");
|
||||||
|
fireEvent.change(box(), { target: { value: "/deplo" } });
|
||||||
|
const options = await screen.findAllByRole("option");
|
||||||
|
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
|
||||||
|
});
|
||||||
|
|
||||||
it("suggests files under the folder on @", async () => {
|
it("suggests files under the folder on @", async () => {
|
||||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||||
@@ -166,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||||
expect(api.run.dropImage).toHaveBeenCalledWith(img);
|
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||||
// The path is not typed into the prompt; it goes with it on send.
|
// The path is not typed into the prompt; it goes with it on send.
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||||
|
|
||||||
@@ -175,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
|
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
|
||||||
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||||
|
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value="read"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||||
|
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves a text paste to the textarea", () => {
|
it("leaves a text paste to the textarea", () => {
|
||||||
renderEditor("");
|
renderEditor("");
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||||
expect(api.run.dropImage).not.toHaveBeenCalled();
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +267,7 @@ describe("ModelPicker", () => {
|
|||||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
|
||||||
render(
|
render(
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value=""
|
value=""
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
* @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
|
* a WS connection to the right URL, writes incoming binary frames to the
|
||||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
* 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) with backoff, but not
|
||||||
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* and recolours live on a dark-theme change without re-attaching.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { TerminalView } from "../TerminalView";
|
import { TerminalView, loadXterm } from "../TerminalView";
|
||||||
import { pasteToRun } from "../../../lib/panePaste";
|
import { pasteToRun } from "../../../lib/panePaste";
|
||||||
|
|
||||||
const writeMock = vi.fn();
|
const writeMock = vi.fn();
|
||||||
@@ -20,9 +21,9 @@ const wheelHandlerMock = vi.fn();
|
|||||||
const keyHandlerMock = vi.fn();
|
const keyHandlerMock = vi.fn();
|
||||||
const focusMock = vi.fn();
|
const focusMock = vi.fn();
|
||||||
const pasteMock = vi.fn();
|
const pasteMock = vi.fn();
|
||||||
const dropImageMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||||
}));
|
}));
|
||||||
const loadAddonMock = vi.fn();
|
const loadAddonMock = vi.fn();
|
||||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||||
@@ -87,7 +88,7 @@ class MockWebSocket {
|
|||||||
sent: unknown[] = [];
|
sent: unknown[] = [];
|
||||||
onopen: (() => void) | null = null;
|
onopen: (() => void) | null = null;
|
||||||
onmessage: ((e: { data: unknown }) => void) | null = null;
|
onmessage: ((e: { data: unknown }) => void) | null = null;
|
||||||
onclose: (() => void) | null = null;
|
onclose: ((e?: { code: number }) => void) | null = null;
|
||||||
readyState = 1; // OPEN — the component only sends on an open socket
|
readyState = 1; // OPEN — the component only sends on an open socket
|
||||||
static OPEN = 1;
|
static OPEN = 1;
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
@@ -105,6 +106,9 @@ class MockWebSocket {
|
|||||||
global.WebSocket = MockWebSocket;
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
describe("TerminalView", () => {
|
describe("TerminalView", () => {
|
||||||
|
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||||
|
beforeAll(() => loadXterm());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
termState.modes.mouseTrackingMode = "none";
|
termState.modes.mouseTrackingMode = "none";
|
||||||
termState.buffer.active.type = "alternate";
|
termState.buffer.active.type = "alternate";
|
||||||
@@ -388,6 +392,23 @@ describe("TerminalView", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("backs off between failed re-attaches, and stops on a policy close (1008)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
MockWebSocket.instances[0]!.close();
|
||||||
|
vi.advanceTimersByTime(1600);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(2);
|
||||||
|
MockWebSocket.instances[1]!.close(); // failed again: now 3 s
|
||||||
|
vi.advanceTimersByTime(1600);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(2);
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
act(() => MockWebSocket.instances[2]!.onclose?.({ code: 1008 }));
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not re-attach once the server reported the pane exited", () => {
|
it("does not re-attach once the server reported the pane exited", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
@@ -400,29 +421,36 @@ describe("TerminalView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
|
||||||
pasteMock.mockClear();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||||
const target = screen.getByTestId("terminal-view");
|
const target = screen.getByTestId("terminal-view");
|
||||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
|
||||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
await waitFor(() =>
|
||||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||||
|
);
|
||||||
|
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
it("pastes every path of several dropped files, images or not", async () => {
|
||||||
dropImageMock.mockReset();
|
dropFileMock
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||||
pasteMock.mockClear();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||||
|
const img = new File(["x"], "b.png", { type: "image/png" });
|
||||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
dataTransfer: { types: ["Files"], files: [txt] },
|
dataTransfer: { types: ["Files"], files: [txt, img] },
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
await waitFor(() =>
|
||||||
expect(dropImageMock).not.toHaveBeenCalled();
|
expect(pasteMock).toHaveBeenCalledWith(
|
||||||
expect(pasteMock).not.toHaveBeenCalled();
|
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.tsx
|
* @file AppearanceSettings.tsx
|
||||||
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
|
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||||
|
* pages redirect here. Theme state is 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
|
* 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.
|
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||||
@@ -11,7 +13,8 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||||
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
|
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||||
|
import { PetSettings } from "./PetSettings";
|
||||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -20,7 +23,6 @@ const THEME_LABELS: Record<string, string> = {
|
|||||||
darcula: "Darcula",
|
darcula: "Darcula",
|
||||||
"apple-dark": "Apple Dark",
|
"apple-dark": "Apple Dark",
|
||||||
"apple-light": "Apple Light",
|
"apple-light": "Apple Light",
|
||||||
"intellij-light": "IntelliJ Light",
|
|
||||||
canvas: "Canvas",
|
canvas: "Canvas",
|
||||||
"orca-dark": "Orca Dark",
|
"orca-dark": "Orca Dark",
|
||||||
"orca-light": "Orca Light",
|
"orca-light": "Orca Light",
|
||||||
@@ -61,7 +63,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t, i18n } = useTranslation("settings");
|
||||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
@@ -78,7 +80,30 @@ export function AppearanceSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-5 space-y-5">
|
<div className="space-y-6">
|
||||||
|
<SettingsSection
|
||||||
|
id="language"
|
||||||
|
title={t("language.title")}
|
||||||
|
description={t("language.description")}
|
||||||
|
>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("language.label")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("language.label")}
|
||||||
|
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||||
|
onChange={(v) => void i18n.changeLanguage(v)}
|
||||||
|
options={[
|
||||||
|
{ value: "en" as const, label: t("language.names.en") },
|
||||||
|
{ value: "vi" as const, label: t("language.names.vi") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||||
|
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={t("appearance.mode")}
|
label={t("appearance.mode")}
|
||||||
control={
|
control={
|
||||||
@@ -139,6 +164,15 @@ export function AppearanceSettings() {
|
|||||||
{t("appearance.reset")}
|
{t("appearance.reset")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="pet" className="space-y-3 scroll-mt-24">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
|
||||||
|
</div>
|
||||||
|
<PetSettings />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* @file AutostartSettings — Settings → About's "Start automatically" switch:
|
||||||
|
* whether the dashboard starts at boot/login through the per-user service
|
||||||
|
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||||
|
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||||
|
* stops the running server.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type AutostartStatus } from "../../lib/api";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function AutostartSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.autostart
|
||||||
|
.get()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const change = async (enabled: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
setStatus(await api.settings.autostart.set(enabled));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const note = !status
|
||||||
|
? undefined
|
||||||
|
: !status.available
|
||||||
|
? t("autostart.unavailable")
|
||||||
|
: status.foreign
|
||||||
|
? t("autostart.foreign")
|
||||||
|
: t(`autostart.desc_${status.kind}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection id="autostart" title={t("autostart.title")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("autostart.label")}
|
||||||
|
description={note}
|
||||||
|
checked={!!status?.enabled}
|
||||||
|
disabled={!status?.available || status.foreign || saving}
|
||||||
|
onChange={(v) => void change(v)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
|||||||
// badge current the way the old Settings page did, every 10 s.
|
// badge current the way the old Settings page did, every 10 s.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
|
if (!(document.visibilityState === "visible")) return;
|
||||||
api.settings
|
api.settings
|
||||||
.info()
|
.info()
|
||||||
.then((r) => setHooks(r.hooks))
|
.then((r) => setHooks(r.hooks))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
|
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
|
||||||
* its speech bubbles, its size (with a live preview), and which pet it wears
|
* its speech bubbles, its size (with a live preview), and which pet it wears
|
||||||
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
|
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
|
||||||
* install on the first pick, never bundled), and your own (deletable) — plus
|
* install on the first pick, never bundled), and your own (deletable) — plus
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||||
* and Codex, the Antigravity tab itself): Claude
|
* and Codex, the Antigravity tab itself): Claude
|
||||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
|
||||||
* **Show all models** switch lists every row, and a row added outside the
|
* **Show all models** switch lists every row, and a row added outside the
|
||||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* @file PrivacySettings.tsx
|
||||||
|
* @description Settings → Data → Privacy: the two opt-in ingest controls of
|
||||||
|
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
|
||||||
|
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
|
||||||
|
* and a list of folders whose hooks are dropped whole (one absolute path per
|
||||||
|
* line; a folder and everything under it). Both persist through
|
||||||
|
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, ShieldCheck } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { PrivacySettings as PrivacyState } from "../../lib/api";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
const lines = (text: string) =>
|
||||||
|
text
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export function PrivacySettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [state, setState] = useState<PrivacyState | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apply = (s: PrivacyState) => {
|
||||||
|
setState(s);
|
||||||
|
setDraft(s.ignore_cwd.join("\n"));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.privacy
|
||||||
|
.get()
|
||||||
|
.then(apply)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
apply(await api.settings.privacy.save(patch));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="privacy" className="scroll-mt-24">
|
||||||
|
<SettingsSection
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-fg-muted" />
|
||||||
|
{t("privacy.title")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={t("privacy.description")}
|
||||||
|
>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("privacy.redact")}
|
||||||
|
description={t("privacy.redactHint")}
|
||||||
|
checked={!!state?.redact_secrets}
|
||||||
|
disabled={!state || saving || state.env_owned.redact_secrets}
|
||||||
|
onChange={(v) => void save({ redact_secrets: v })}
|
||||||
|
/>
|
||||||
|
<div className="py-3 space-y-2">
|
||||||
|
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
|
||||||
|
{t("privacy.ignore")}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
|
||||||
|
<textarea
|
||||||
|
id="privacy-ignore"
|
||||||
|
className="input w-full font-mono text-xs min-h-[72px]"
|
||||||
|
placeholder={"/home/you/clients/secret-project\n~/private"}
|
||||||
|
value={draft}
|
||||||
|
disabled={!state || state.env_owned.ignore_cwd}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={saving || !listChanged}
|
||||||
|
onClick={() => void save({ ignore_cwd: lines(draft) })}
|
||||||
|
>
|
||||||
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||||
|
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
|
||||||
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||||
|
{t("privacy.envOwned")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* @file PushIngestSettings.tsx
|
||||||
|
* @description Settings → Data → Push ingest: the bearer token that opens
|
||||||
|
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
|
||||||
|
* over SSH, so the machine pushes its hooks instead. Off until a token is
|
||||||
|
* created; the token is shown once, right after it is made (only its hash is
|
||||||
|
* stored), and can be rotated or revoked. Also shows a forwarder script for
|
||||||
|
* the other machine — its token in a header file, never on a command line.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { PushTokenStatus } from "../../lib/api";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
|
|
||||||
|
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||||
|
function forwarder(origin: string) {
|
||||||
|
return `#!/bin/sh
|
||||||
|
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
|
||||||
|
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||||
|
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||||
|
# Authorization: Bearer <token>
|
||||||
|
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
|
||||||
|
'{source: $s, events: [{hook_type: $t, data: .}]}' |
|
||||||
|
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
|
||||||
|
-H "@$HOME/.claude/agenthub-push.header" \\
|
||||||
|
-H "Content-Type: application/json" --data-binary @-
|
||||||
|
exit 0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushIngestSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<PushTokenStatus | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Rotating a live token or revoking one asks first.
|
||||||
|
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.pushToken
|
||||||
|
.get()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function run(fn: () => Promise<void>) {
|
||||||
|
setConfirm(null);
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rotate = () =>
|
||||||
|
run(async () => {
|
||||||
|
const r = await api.settings.pushToken.rotate();
|
||||||
|
setToken(r.token);
|
||||||
|
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
|
||||||
|
});
|
||||||
|
const revoke = () =>
|
||||||
|
run(async () => {
|
||||||
|
setStatus(await api.settings.pushToken.revoke());
|
||||||
|
setToken(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = forwarder(window.location.origin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="push-ingest" className="scroll-mt-24">
|
||||||
|
<SettingsSection
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||||
|
{t("pushIngest.title")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={t("pushIngest.description")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
|
||||||
|
>
|
||||||
|
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary text-xs disabled:opacity-40"
|
||||||
|
disabled={busy || !status || status.env_owned}
|
||||||
|
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
|
||||||
|
</button>
|
||||||
|
{status?.enabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||||
|
disabled={busy || status.env_owned}
|
||||||
|
onClick={() => setConfirm("revoke")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{t("pushIngest.revoke")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{token && (
|
||||||
|
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
|
||||||
|
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
title={t("pushIngest.copy")}
|
||||||
|
onClick={() => void copyText(token)}
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status?.env_owned && (
|
||||||
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||||
|
{t("pushIngest.envOwned")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
|
||||||
|
{script}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs absolute top-1.5 right-1.5"
|
||||||
|
title={t("pushIngest.copy")}
|
||||||
|
onClick={() => void copyText(script)}
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
|
||||||
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirm !== null}
|
||||||
|
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
|
||||||
|
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
destructive={confirm === "revoke"}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* @file ReportSettings.tsx
|
||||||
|
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
|
||||||
|
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
|
||||||
|
* enable switch, Run now, a confirmed Delete, and each report's run history
|
||||||
|
* with download links. Every row action is also on a right-click menu. The
|
||||||
|
* browser's timezone offset is saved with the definition, so its hour is the
|
||||||
|
* user's local hour.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
import { Switch } from "../Switch";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
const EMPTY: ReportInput = {
|
||||||
|
name: "",
|
||||||
|
window_days: 7,
|
||||||
|
frequency: "weekly",
|
||||||
|
hour: 8,
|
||||||
|
weekday: 1,
|
||||||
|
tz_offset: 0,
|
||||||
|
formats: ["html"],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
export function ReportSettings() {
|
||||||
|
const { t, i18n } = useTranslation("settings");
|
||||||
|
const [items, setItems] = useState<ReportDefinition[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** The form: a new report (id null) or the one being edited. */
|
||||||
|
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
|
||||||
|
const [busy, setBusy] = useState<number | "form" | null>(null);
|
||||||
|
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
|
||||||
|
const ctx = useContextMenu<ReportDefinition>();
|
||||||
|
|
||||||
|
const weekday = (d: number) =>
|
||||||
|
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
|
||||||
|
new Date(Date.UTC(2026, 0, 4 + d))
|
||||||
|
);
|
||||||
|
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.reports
|
||||||
|
.list()
|
||||||
|
.then((r) => setItems(r.reports))
|
||||||
|
.catch(fail);
|
||||||
|
}, []);
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const showHistory = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const r = await api.reports.runs(id);
|
||||||
|
setHistory({ id, runs: r.runs });
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
setBusy("form");
|
||||||
|
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
|
||||||
|
try {
|
||||||
|
if (form.id === null) await api.reports.create(value);
|
||||||
|
else await api.reports.update(form.id, value);
|
||||||
|
setForm(null);
|
||||||
|
setError(null);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (r: ReportDefinition, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.reports.update(r.id, { enabled });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runNow = async (r: ReportDefinition) => {
|
||||||
|
setBusy(r.id);
|
||||||
|
try {
|
||||||
|
const { run } = await api.reports.run(r.id);
|
||||||
|
if (run.status === "error") setError(run.error);
|
||||||
|
load();
|
||||||
|
if (history?.id === r.id) await showHistory(r.id);
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
try {
|
||||||
|
await api.reports.remove(deleting.id);
|
||||||
|
if (history?.id === deleting.id) setHistory(null);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = (r: ReportDefinition) =>
|
||||||
|
setForm({
|
||||||
|
id: r.id,
|
||||||
|
value: {
|
||||||
|
name: r.name,
|
||||||
|
window_days: r.window_days,
|
||||||
|
frequency: r.frequency,
|
||||||
|
hour: r.hour,
|
||||||
|
weekday: r.weekday,
|
||||||
|
tz_offset: r.tz_offset,
|
||||||
|
formats: r.formats,
|
||||||
|
enabled: r.enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pick = (fn: (r: ReportDefinition) => void) => () => {
|
||||||
|
const r = ctx.menu?.item;
|
||||||
|
ctx.close();
|
||||||
|
if (r) fn(r);
|
||||||
|
};
|
||||||
|
const schedule = (r: ReportInput) =>
|
||||||
|
[
|
||||||
|
r.frequency === "daily"
|
||||||
|
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
|
||||||
|
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
|
||||||
|
t("reports.window", { count: r.window_days }),
|
||||||
|
r.formats.map((f) => f.toUpperCase()).join(", "),
|
||||||
|
].join(" · ");
|
||||||
|
|
||||||
|
const set = (patch: Partial<ReportInput>) =>
|
||||||
|
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
|
||||||
|
const toggleFormat = (f: "html" | "json", on: boolean) =>
|
||||||
|
set({
|
||||||
|
formats: on
|
||||||
|
? [...(form?.value.formats ?? []), f]
|
||||||
|
: (form?.value.formats ?? []).filter((x) => x !== f),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form ? (
|
||||||
|
<div
|
||||||
|
className="my-2 space-y-3 rounded-md border border-border p-3"
|
||||||
|
data-testid="report-form"
|
||||||
|
>
|
||||||
|
<label className="block text-xs text-fg-muted">
|
||||||
|
{t("reports.name")}
|
||||||
|
<input
|
||||||
|
className="input mt-1 w-full text-sm"
|
||||||
|
value={form.value.name}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-xs text-fg-muted">
|
||||||
|
{t("reports.windowDays")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={366}
|
||||||
|
className="input mt-1 block w-24 text-sm"
|
||||||
|
value={form.value.window_days}
|
||||||
|
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="w-36">
|
||||||
|
<Select
|
||||||
|
value={form.value.frequency}
|
||||||
|
onChange={(v) => set({ frequency: v })}
|
||||||
|
label={t("reports.frequency")}
|
||||||
|
options={[
|
||||||
|
{ value: "daily", label: t("reports.daily") },
|
||||||
|
{ value: "weekly", label: t("reports.weekly") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{form.value.frequency === "weekly" && (
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={String(form.value.weekday)}
|
||||||
|
onChange={(v) => set({ weekday: Number(v) })}
|
||||||
|
label={t("reports.weekday")}
|
||||||
|
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
|
||||||
|
value: String(d),
|
||||||
|
label: weekday(d),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-28">
|
||||||
|
<Select
|
||||||
|
value={String(form.value.hour)}
|
||||||
|
onChange={(v) => set({ hour: Number(v) })}
|
||||||
|
label={t("reports.hour")}
|
||||||
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.value.formats.includes("html")}
|
||||||
|
onChange={(v) => toggleFormat("html", v)}
|
||||||
|
label="HTML"
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={form.value.formats.includes("json")}
|
||||||
|
onChange={(v) => toggleFormat("json", v)}
|
||||||
|
label="JSON"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
disabled={
|
||||||
|
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
|
||||||
|
}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{t("reports.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
|
||||||
|
{t("reports.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost my-2 text-xs"
|
||||||
|
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("reports.add")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border/60" data-testid="report-list">
|
||||||
|
{items.map((r) => (
|
||||||
|
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
|
||||||
|
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
|
||||||
|
{r.last_run && (
|
||||||
|
<p
|
||||||
|
className={`text-[11px] ${
|
||||||
|
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
|
||||||
|
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={r.enabled}
|
||||||
|
onChange={(v) => void toggle(r, v)}
|
||||||
|
label={t("reports.enabled")}
|
||||||
|
title={t("reports.enabled")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
disabled={busy === r.id}
|
||||||
|
onClick={() => void runNow(r)}
|
||||||
|
title={t("reports.runNow")}
|
||||||
|
aria-label={t("reports.runNow")}
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
|
||||||
|
title={t("reports.history")}
|
||||||
|
aria-label={t("reports.history")}
|
||||||
|
>
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
onClick={() => edit(r)}
|
||||||
|
title={t("reports.edit")}
|
||||||
|
aria-label={t("reports.edit")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-xs text-status-danger"
|
||||||
|
onClick={() => setDeleting(r)}
|
||||||
|
title={t("reports.delete")}
|
||||||
|
aria-label={t("reports.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{history?.id === r.id && (
|
||||||
|
<ul
|
||||||
|
className="mt-2 space-y-1 border-l border-border pl-3"
|
||||||
|
data-testid="report-runs"
|
||||||
|
>
|
||||||
|
{history.runs.length === 0 && (
|
||||||
|
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
|
||||||
|
)}
|
||||||
|
{history.runs.map((run) => (
|
||||||
|
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
|
||||||
|
</span>
|
||||||
|
{run.status === "error" ? (
|
||||||
|
<span className="text-status-danger">{run.error}</span>
|
||||||
|
) : (
|
||||||
|
run.files.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f}
|
||||||
|
href={api.reports.fileUrl(r.id, run.id, f)}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-0.5 text-accent hover:underline"
|
||||||
|
title={t("reports.download", { format: f.toUpperCase() })}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
|
||||||
|
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
|
||||||
|
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
|
||||||
|
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||||
|
{t("reports.delete")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleting !== null}
|
||||||
|
title={t("reports.deleteTitle")}
|
||||||
|
message={t("reports.deleteBody")}
|
||||||
|
confirmLabel={t("reports.delete")}
|
||||||
|
cancelLabel={t("reports.cancel")}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* @file SnapshotSettings.tsx
|
||||||
|
* @description Settings → Snapshots: every frozen session (`GET
|
||||||
|
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
|
||||||
|
* buttons and the same actions on a right-click menu. Snapshots survive Clear
|
||||||
|
* Data, so this is the one place they go away.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import type { SessionSnapshotInfo } from "../../lib/types";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { CopyButton } from "../event-views/primitives";
|
||||||
|
import { snapshotUrl } from "../SnapshotButton";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function SnapshotSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const ctx = useContextMenu<SessionSnapshotInfo>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.snapshots
|
||||||
|
.list()
|
||||||
|
.then((r) => setItems(r.snapshots))
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}, []);
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.snapshots.remove(deleting.id);
|
||||||
|
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
|
||||||
|
const s = ctx.menu?.item;
|
||||||
|
ctx.close();
|
||||||
|
if (s) fn(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
|
||||||
|
{items.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
onContextMenu={(e) => ctx.open(e, s)}
|
||||||
|
className="flex items-center gap-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-fg-secondary">
|
||||||
|
{s.session_name || s.session_id.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
|
||||||
|
<span>{formatDateTime(s.created_at)}</span>
|
||||||
|
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
|
||||||
|
{s.redacted && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5"
|
||||||
|
title={t("snapshots.redacted")}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
{t("snapshots.redacted")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/snapshot/${s.token}`}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
title={t("snapshots.open")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
<CopyButton text={snapshotUrl(s.token)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleting(s)}
|
||||||
|
className="btn-ghost text-xs text-status-danger"
|
||||||
|
title={t("snapshots.delete")}
|
||||||
|
aria-label={t("snapshots.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
|
||||||
|
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||||
|
{t("snapshots.open")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
||||||
|
>
|
||||||
|
{t("snapshots.copyLink")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger onSelect={pick(setDeleting)}>
|
||||||
|
{t("snapshots.delete")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleting !== null}
|
||||||
|
title={t("snapshots.deleteTitle")}
|
||||||
|
message={t("snapshots.deleteBody")}
|
||||||
|
confirmLabel={t("snapshots.delete")}
|
||||||
|
cancelLabel={t("snapshots.cancel")}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* @file UsersSettings.tsx
|
||||||
|
* @description Settings → Users, server mode only and only for an admin or
|
||||||
|
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
||||||
|
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
||||||
|
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
||||||
|
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||||
|
* `agenthub users …` on the host.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||||
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function UsersSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
|
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch((e: Error) => setError(e.message));
|
||||||
|
api.users
|
||||||
|
.audit()
|
||||||
|
.then((r) => setAudit(r.entries))
|
||||||
|
.catch(() => setAudit([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{users && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm" data-testid="users-table">
|
||||||
|
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||||
|
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
{u.username}
|
||||||
|
{u.disabled && (
|
||||||
|
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">{u.role}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||||
|
<td
|
||||||
|
className="py-2 pr-3 text-right tabular-nums"
|
||||||
|
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||||
|
>
|
||||||
|
{fmtCost(u.last7d.cost)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-2 text-right tabular-nums"
|
||||||
|
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||||
|
>
|
||||||
|
{fmtCost(u.last30d.cost)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||||
|
{audit?.length === 0 && (
|
||||||
|
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
||||||
|
)}
|
||||||
|
{audit && audit.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||||
|
{audit.map((a) => (
|
||||||
|
<li key={a.id} className="flex gap-3 py-1.5">
|
||||||
|
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||||
|
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||||
|
{a.reader} → {a.owner}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
prompt: { mode: "argv" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.test.tsx
|
* @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>`.
|
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { act, render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import i18n from "../../../i18n";
|
||||||
import { AppearanceSettings } from "../AppearanceSettings";
|
import { AppearanceSettings } from "../AppearanceSettings";
|
||||||
|
|
||||||
|
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
describe("AppearanceSettings", () => {
|
describe("AppearanceSettings", () => {
|
||||||
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
|
|||||||
root.removeAttribute("data-mode");
|
root.removeAttribute("data-mode");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches the interface language and holds the pet block", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||||
|
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||||
|
expect(i18n.language).toBe("vi");
|
||||||
|
await act(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders every theme of both modes, defaults selected", () => {
|
it("renders every theme of both modes, defaults selected", () => {
|
||||||
render(<AppearanceSettings />);
|
render(<AppearanceSettings />);
|
||||||
const pressed = screen
|
const pressed = screen
|
||||||
@@ -31,7 +50,6 @@ describe("AppearanceSettings", () => {
|
|||||||
"Apple Dark",
|
"Apple Dark",
|
||||||
"Orca Dark",
|
"Orca Dark",
|
||||||
"Apple Light",
|
"Apple Light",
|
||||||
"IntelliJ Light",
|
|
||||||
"Canvas",
|
"Canvas",
|
||||||
"Orca Light",
|
"Orca Light",
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @file PushIngestSettings.test.tsx
|
||||||
|
* @description Settings → Data → Push ingest and Privacy: a created token is
|
||||||
|
* shown once (a reload shows only that push is on), and the ignored-folder
|
||||||
|
* list is saved one path per line with blank lines dropped.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
|
const { pushToken, privacy } = vi.hoisted(() => ({
|
||||||
|
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
|
||||||
|
privacy: { get: vi.fn(), save: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
|
||||||
|
|
||||||
|
import { PushIngestSettings } from "../PushIngestSettings";
|
||||||
|
import { PrivacySettings } from "../PrivacySettings";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PushIngestSettings", () => {
|
||||||
|
it("shows a new token once, and never on a fresh load", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
|
||||||
|
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
|
||||||
|
const view = render(<PushIngestSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: /Create token/ }));
|
||||||
|
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
|
||||||
|
render(<PushIngestSettings />);
|
||||||
|
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ahp_secret")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PrivacySettings", () => {
|
||||||
|
it("saves the ignored folders one per line", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const state = {
|
||||||
|
redact_secrets: false,
|
||||||
|
ignore_cwd: [],
|
||||||
|
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||||
|
};
|
||||||
|
privacy.get.mockResolvedValue(state);
|
||||||
|
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
|
||||||
|
render(<PrivacySettings />);
|
||||||
|
const box = await screen.findByLabelText("Ignored folders");
|
||||||
|
await user.type(box, "/a/b{enter}{enter}/c");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @file ReportSettings.test.tsx
|
||||||
|
* @description Settings → Reports: a new report is saved with the browser's
|
||||||
|
* timezone offset, Run now runs it, and History lists the runs with download
|
||||||
|
* links built from the report and run ids.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
|
||||||
|
const { reports } = vi.hoisted(() => ({
|
||||||
|
reports: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
runs: vi.fn(),
|
||||||
|
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", () => ({ api: { reports } }));
|
||||||
|
|
||||||
|
import { ReportSettings } from "../ReportSettings";
|
||||||
|
|
||||||
|
const DEF = {
|
||||||
|
id: 3,
|
||||||
|
name: "Weekly summary",
|
||||||
|
window_days: 7,
|
||||||
|
frequency: "weekly" as const,
|
||||||
|
hour: 8,
|
||||||
|
weekday: 1,
|
||||||
|
tz_offset: 0,
|
||||||
|
formats: ["html" as const, "json" as const],
|
||||||
|
enabled: true,
|
||||||
|
created_at: "2026-10-01T00:00:00Z",
|
||||||
|
last_slot: null,
|
||||||
|
last_run: null,
|
||||||
|
};
|
||||||
|
const RUN = {
|
||||||
|
id: 9,
|
||||||
|
report_id: 3,
|
||||||
|
created_at: "2026-10-05T01:00:00Z",
|
||||||
|
trigger: "manual" as const,
|
||||||
|
status: "ok" as const,
|
||||||
|
window_from: "2026-09-28",
|
||||||
|
window_to: "2026-10-04",
|
||||||
|
files: ["html", "json"],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportSettings", () => {
|
||||||
|
it("creates a report with the browser's timezone offset", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
|
||||||
|
reports.create.mockResolvedValue({ report: DEF });
|
||||||
|
render(<ReportSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: /New report/ }));
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(reports.create).toHaveBeenCalledWith({
|
||||||
|
name: "Weekly summary",
|
||||||
|
window_days: 7,
|
||||||
|
frequency: "weekly",
|
||||||
|
hour: 8,
|
||||||
|
weekday: 1,
|
||||||
|
tz_offset: new Date().getTimezoneOffset(),
|
||||||
|
formats: ["html", "json"],
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs a report now and lists its runs with download links", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
reports.list.mockResolvedValue({ reports: [DEF] });
|
||||||
|
reports.run.mockResolvedValue({ run: RUN });
|
||||||
|
reports.runs.mockResolvedValue({ runs: [RUN] });
|
||||||
|
render(<ReportSettings />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||||
|
expect(reports.run).toHaveBeenCalledWith(3);
|
||||||
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
const link = await screen.findByRole("link", { name: /HTML/ });
|
||||||
|
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
|
||||||
|
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
|
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||||
import type { PipelineStats } from "../../lib/types";
|
import type { PipelineStats } from "../../lib/types";
|
||||||
|
|
||||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||||
@@ -52,8 +53,12 @@ export function LanePipelinePanel({
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Stages move as agents declare them; throttled (not debounced) so a burst
|
// Stages move as agents declare them; throttled (not debounced) so a burst
|
||||||
// costs one fetch and a steady stream still refreshes every 3 s.
|
// costs one fetch and a steady stream still refreshes every 3 s. A lane's
|
||||||
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
|
// re-broadcast that moved no stage column is ignored.
|
||||||
|
const [stageChanged] = useState(() =>
|
||||||
|
laneChangeMatcher((l) => `${l.pipeline}:${l.stage}:${l.active_feature_id}`)
|
||||||
|
);
|
||||||
|
useLiveRefresh(() => void refresh(), { match: stageChanged });
|
||||||
|
|
||||||
const hasPipelines = !!data && data.pipelines.length > 0;
|
const hasPipelines = !!data && data.pipelines.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
|
// Throttled (not debounced): a steady event stream still refreshes, every
|
||||||
|
// 15 s — it is an aggregate over every event.
|
||||||
useLiveRefresh(() => void refresh(), {
|
useLiveRefresh(() => void refresh(), {
|
||||||
|
throttleMs: 15000,
|
||||||
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file useSpin.test.ts
|
||||||
|
* @description Tests the Refresh-button hook: the reload runs with no
|
||||||
|
* arguments, a click while it is in flight is dropped, and the spin lasts at
|
||||||
|
* least the floor even when the reload answers at once — also under StrictMode's
|
||||||
|
* mount → unmount → mount, which once left it spinning for good.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useSpin } from "../useSpin";
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("useSpin", () => {
|
||||||
|
it("drops a click while the reload is in flight, and spins for the floor after it answers", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let answer!: () => void;
|
||||||
|
const load = vi.fn(() => new Promise<void>((r) => (answer = r)));
|
||||||
|
const { result } = renderHook(() => useSpin(load, 600));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
expect(load).toHaveBeenCalledWith();
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Answered at once: still spinning until the floor, but a new click reloads.
|
||||||
|
await act(async () => answer());
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => {
|
||||||
|
answer();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops spinning under StrictMode's double mount", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result } = renderHook(() => useSpin(() => undefined, 600), { wrapper: StrictMode });
|
||||||
|
await act(async () => {
|
||||||
|
void result.current[1]();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file useThrottledValue.test.ts
|
||||||
|
* @description Tests for `useThrottledValue`: the first change lands at once, a
|
||||||
|
* burst inside the window collapses into one trailing update with the last value.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useThrottledValue } from "../useThrottledValue";
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("useThrottledValue", () => {
|
||||||
|
it("is leading + trailing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result, rerender } = renderHook(({ v }) => useThrottledValue(v, 3000), {
|
||||||
|
initialProps: { v: "a" },
|
||||||
|
});
|
||||||
|
expect(result.current).toBe("a");
|
||||||
|
act(() => vi.advanceTimersByTime(5000));
|
||||||
|
rerender({ v: "b" });
|
||||||
|
expect(result.current).toBe("b"); // leading
|
||||||
|
rerender({ v: "c" });
|
||||||
|
rerender({ v: "d" });
|
||||||
|
expect(result.current).toBe("b"); // inside the window
|
||||||
|
act(() => vi.advanceTimersByTime(3000));
|
||||||
|
expect(result.current).toBe("d"); // trailing, last value
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,8 +36,9 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
|||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const live = throttle(() => loadRef.current(), throttleMs);
|
const live = throttle(() => loadRef.current(), throttleMs);
|
||||||
const visible = () => document.visibilityState === "visible";
|
const visible = () => document.visibilityState === "visible";
|
||||||
|
// A hidden tab ignores events: onVis reloads once when it is shown again.
|
||||||
const offMsg = eventBus.subscribe((msg) => {
|
const offMsg = eventBus.subscribe((msg) => {
|
||||||
if (matchRef.current?.(msg)) live();
|
if (visible() && matchRef.current?.(msg)) live();
|
||||||
});
|
});
|
||||||
let wasDown = !eventBus.connected;
|
let wasDown = !eventBus.connected;
|
||||||
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
||||||
@@ -47,7 +48,7 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
|||||||
if (!up) wasDown = true;
|
if (!up) wasDown = true;
|
||||||
else if (wasDown) {
|
else if (wasDown) {
|
||||||
wasDown = false;
|
wasDown = false;
|
||||||
live();
|
if (visible()) live();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: () => undefined;
|
: () => undefined;
|
||||||
|
|||||||
@@ -121,9 +121,11 @@ export function useNotifications() {
|
|||||||
type: NotifType,
|
type: NotifType,
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
url?: Promise<string | undefined>
|
// A thunk: the pane lookup (GET /api/run) runs only for a
|
||||||
|
// notification that is actually shown, never per incoming event.
|
||||||
|
url?: () => Promise<string | undefined>
|
||||||
) =>
|
) =>
|
||||||
void Promise.resolve(url)
|
void Promise.resolve(url?.())
|
||||||
.then((u) => notify(title, body, u))
|
.then((u) => notify(title, body, u))
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||||
@@ -138,7 +140,7 @@ export function useNotifications() {
|
|||||||
"onNewSession",
|
"onNewSession",
|
||||||
i18n.t("errors:notifications.newSession"),
|
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)
|
() => laneUrlForSession(s.id)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ export function useNotifications() {
|
|||||||
"onSessionError",
|
"onSessionError",
|
||||||
i18n.t("errors:notifications.sessionError"),
|
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)
|
() => laneUrlForSession(s.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -158,10 +160,7 @@ export function useNotifications() {
|
|||||||
if (!prefs.onSubagentSpawn) return;
|
if (!prefs.onSubagentSpawn) return;
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a.type === "subagent") {
|
if (a.type === "subagent") {
|
||||||
show(
|
show("onSubagentSpawn", i18n.t("errors:notifications.subagentSpawned"), a.name, () =>
|
||||||
"onSubagentSpawn",
|
|
||||||
i18n.t("errors:notifications.subagentSpawned"),
|
|
||||||
a.name,
|
|
||||||
laneUrlForSession(a.session_id)
|
laneUrlForSession(a.session_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,7 +168,7 @@ export function useNotifications() {
|
|||||||
}
|
}
|
||||||
case "new_event": {
|
case "new_event": {
|
||||||
const ev = msg.data as DashboardEvent;
|
const ev = msg.data as DashboardEvent;
|
||||||
const url = laneUrlForSession(ev.session_id);
|
const url = () => laneUrlForSession(ev.session_id);
|
||||||
// The two states where Claude is BLOCKED on the user get their own
|
// The two states where Claude is BLOCKED on the user get their own
|
||||||
// flags (issue #29): `Notification` is a permission prompt or a
|
// flags (issue #29): `Notification` is a permission prompt or a
|
||||||
// question, `Stop` is the end of a turn. Both used to hang off
|
// question, `Stop` is the end of a turn. Both used to hang off
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @file useSpin.ts
|
||||||
|
* @description A Refresh button's busy state. `run` calls the reload with no
|
||||||
|
* arguments (an `onClick={load}` handed the click event to it) and keeps
|
||||||
|
* `spinning` true for at least `minMs`, so a reload answered from a cache or
|
||||||
|
* a throttle in a few milliseconds still shows the user that it ran.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns `[spinning, run]`. A click while the reload is still in flight is
|
||||||
|
* dropped; once it has answered, a click reloads again even if the icon is
|
||||||
|
* still finishing its minimum spin.
|
||||||
|
*/
|
||||||
|
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const lastRun = useRef(0);
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
fnRef.current = fn;
|
||||||
|
const alive = useRef(true);
|
||||||
|
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
|
||||||
|
// cleanup-only effect left `alive` false — the spin never stopped and the
|
||||||
|
// button stayed busy for good.
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
return () => {
|
||||||
|
alive.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
const id = ++lastRun.current;
|
||||||
|
const started = Date.now();
|
||||||
|
setSpinning(true);
|
||||||
|
try {
|
||||||
|
await fnRef.current();
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
const rest = minMs - (Date.now() - started);
|
||||||
|
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
|
||||||
|
// Only the newest run stops the spin: an older one's floor ending must
|
||||||
|
// not cut short a reload clicked after it.
|
||||||
|
if (alive.current && id === lastRun.current) setSpinning(false);
|
||||||
|
}
|
||||||
|
}, [minMs]);
|
||||||
|
|
||||||
|
return [spinning, run];
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
|
|||||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||||
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
light: ["default", "apple-light", "canvas", "orca-light"],
|
||||||
} as const satisfies Record<Theme, readonly string[]>;
|
} as const satisfies Record<Theme, readonly string[]>;
|
||||||
|
|
||||||
const MODE_KEY = "theme";
|
const MODE_KEY = "theme";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user