Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a | |||
| 4aae61297f |
@@ -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":
|
||||||
|
|||||||
+19
-7
@@ -54,11 +54,11 @@ The client is a single-page application (SPA) built with modern web technologies
|
|||||||
- **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 → 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
|
- **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
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+39
-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,17 +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 { 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 }))
|
||||||
@@ -82,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();
|
||||||
@@ -129,6 +134,10 @@ 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.
|
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||||
@@ -148,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={
|
||||||
@@ -158,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -423,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">
|
||||||
@@ -524,6 +534,22 @@ 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}
|
||||||
|
|||||||
@@ -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}`} />
|
||||||
|
|||||||
@@ -23,13 +23,14 @@
|
|||||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { RefreshCw, RotateCcw, 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, ToolQuota } from "../lib/types";
|
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
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;
|
||||||
@@ -121,6 +122,7 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
|||||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||||
|
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||||
@@ -208,7 +210,7 @@ function QuotaPanel({
|
|||||||
onMouseLeave={onLeave}
|
onMouseLeave={onLeave}
|
||||||
onFocus={onEnter}
|
onFocus={onEnter}
|
||||||
onBlur={onLeave}
|
onBlur={onLeave}
|
||||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] 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"
|
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>
|
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||||
{groups.map(([id, list]) => (
|
{groups.map(([id, list]) => (
|
||||||
@@ -264,15 +266,15 @@ function QuotaChips() {
|
|||||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||||
};
|
};
|
||||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const load = useCallback(
|
||||||
const load = useCallback((force = false) => {
|
(force = false) =>
|
||||||
if (force) setRefreshing(true);
|
|
||||||
api.pricing
|
api.pricing
|
||||||
.quota(force)
|
.quota(force)
|
||||||
.then((r) => setTools(r.tools))
|
.then((r) => setTools(r.tools))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined),
|
||||||
.finally(() => force && setRefreshing(false));
|
[]
|
||||||
}, []);
|
);
|
||||||
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -281,7 +283,7 @@ function QuotaChips() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="status-bar-quota"
|
data-testid="status-bar-quota"
|
||||||
data-tip-off
|
data-tip-off
|
||||||
onClick={() => load(true)}
|
onClick={refresh}
|
||||||
onMouseEnter={show}
|
onMouseEnter={show}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
onFocus={show}
|
onFocus={show}
|
||||||
@@ -313,7 +315,7 @@ function QuotaChips() {
|
|||||||
tools={tools}
|
tools={tools}
|
||||||
onEnter={show}
|
onEnter={show}
|
||||||
onLeave={hide}
|
onLeave={hide}
|
||||||
onRefresh={() => load(true)}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -342,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;
|
||||||
@@ -381,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 })
|
||||||
@@ -470,6 +481,20 @@ 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 />
|
<QuotaChips />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
*
|
*
|
||||||
* ## 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
|
||||||
@@ -85,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
|
||||||
@@ -171,19 +169,6 @@ 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(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
api.updates
|
|
||||||
.check()
|
|
||||||
.then(syncFromPayload)
|
|
||||||
.catch(() => {});
|
|
||||||
}, AUTO_CHECK_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [syncFromPayload]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// An explicit "Check for updates" undoes both kinds of close.
|
// An explicit "Check for updates" undoes both kinds of close.
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
@@ -264,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(() => {
|
||||||
@@ -279,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;
|
||||||
@@ -292,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(
|
||||||
@@ -330,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">
|
||||||
@@ -370,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
|
|||||||
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],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -142,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();
|
||||||
@@ -221,7 +244,11 @@ describe("StatusBar", () => {
|
|||||||
await user.click(refresh);
|
await user.click(refresh);
|
||||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => answer(same));
|
await act(async () => answer(same));
|
||||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
// 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();
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||||
await user.unhover(panel);
|
await user.unhover(panel);
|
||||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
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: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import { copyText } from "../../lib/clipboard";
|
|||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { useArmed } from "../../hooks/useArmed";
|
import { useArmed } from "../../hooks/useArmed";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||||
import {
|
import {
|
||||||
LaneDiffView,
|
LaneDiffView,
|
||||||
@@ -880,7 +881,10 @@ export function LaneChangesPanel({
|
|||||||
void load();
|
void load();
|
||||||
// Only the cheap shape polls; see the file header.
|
// Only the cheap shape polls; see the file header.
|
||||||
if (mode !== "overview") return;
|
if (mode !== "overview") return;
|
||||||
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
|
const timer = window.setInterval(
|
||||||
|
() => document.visibilityState === "visible" && void load(),
|
||||||
|
OVERVIEW_REFRESH_MS
|
||||||
|
);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [load, mode]);
|
}, [load, mode]);
|
||||||
|
|
||||||
@@ -1418,8 +1422,9 @@ export function LaneChangesPanel({
|
|||||||
setTreeChildren({});
|
setTreeChildren({});
|
||||||
setExpandedDirs(new Set());
|
setExpandedDirs(new Set());
|
||||||
setReread((n) => n + 1);
|
setReread((n) => n + 1);
|
||||||
void load();
|
return load();
|
||||||
};
|
};
|
||||||
|
const [rereading, runReread] = useSpin(rereadAll);
|
||||||
|
|
||||||
const toggleCollapsed = (path: string) =>
|
const toggleCollapsed = (path: string) =>
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
@@ -1606,12 +1611,12 @@ export function LaneChangesPanel({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rereadAll}
|
onClick={runReread}
|
||||||
aria-label={t("changes.refresh")}
|
aria-label={t("changes.refresh")}
|
||||||
title={t("changes.refresh")}
|
title={t("changes.refresh")}
|
||||||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -65,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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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 { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
|
||||||
@@ -66,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
|
||||||
@@ -266,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[]>([]);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,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",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -50,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";
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @file useThrottledValue.ts
|
||||||
|
* @description Returns `value`, but lets it change at most once per `ms`
|
||||||
|
* (leading + trailing, via `lib/throttle.ts`): the first change shows at once,
|
||||||
|
* a burst collapses into one more update when it settles, and the last value
|
||||||
|
* always lands. For effect keys that move on every hook (a lane's status or
|
||||||
|
* session flipping between two sessions) so the fetch they drive cannot storm.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { throttle } from "../lib/throttle";
|
||||||
|
|
||||||
|
export function useThrottledValue<T>(value: T, ms: number): T {
|
||||||
|
const [shown, setShown] = useState(value);
|
||||||
|
const latest = useRef(value);
|
||||||
|
latest.current = value;
|
||||||
|
const push = useMemo(() => throttle(() => setShown(latest.current), ms), [ms]);
|
||||||
|
useEffect(() => () => push.cancel(), [push]);
|
||||||
|
useEffect(() => push(), [value, push]);
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
@@ -128,7 +128,10 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
// restart is picked up within a few seconds rather than after a long
|
// restart is picked up within a few seconds rather than after a long
|
||||||
// idle wait. The focus/online/visibility listeners below reconnect
|
// idle wait. The focus/online/visibility listeners below reconnect
|
||||||
// instantly on top of this for the common "user comes back" case.
|
// instantly on top of this for the common "user comes back" case.
|
||||||
const delay = Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000);
|
// Jittered (×0.5–1.5) so a server restart is not met by every
|
||||||
|
// browser reconnecting in the same instant.
|
||||||
|
const delay =
|
||||||
|
Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000) * (0.5 + Math.random());
|
||||||
reconnectAttempts.current++;
|
reconnectAttempts.current++;
|
||||||
reconnectTimer.current = setTimeout(connect, delay);
|
reconnectTimer.current = setTimeout(connect, delay);
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-88
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file index.ts
|
* @file index.ts
|
||||||
* @description i18next bootstrap for the dashboard client. Registers bundled JSON
|
* @description i18next bootstrap for the dashboard client. Loads the JSON locale
|
||||||
* locale files for English (`en`) and Vietnamese (`vi`), wires browser language detection, and exports the configured
|
* files for English (`en`) and Vietnamese (`vi`) lazily — only the active language
|
||||||
* singleton consumed by `react-i18next`.
|
* (and the `en` fallback) — wires browser language detection, and exports the
|
||||||
|
* configured singleton consumed by `react-i18next` plus `i18nReady`, the promise
|
||||||
|
* the entry point awaits before the first render.
|
||||||
*
|
*
|
||||||
* ## Namespaces
|
* ## Namespaces
|
||||||
* Translations are split by feature area (`dashboard`, `sessions`, `settings`,
|
* Translations are split by feature area (`dashboard`, `sessions`, `settings`,
|
||||||
@@ -38,14 +40,7 @@
|
|||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./locales/en/common.json`
|
* - `./locales/<lng>/<ns>.json` (lazy, via `import.meta.glob`)
|
||||||
* - `./locales/vi/common.json`
|
|
||||||
* - `./locales/en/nav.json`
|
|
||||||
* - `./locales/vi/nav.json`
|
|
||||||
* - `./locales/en/dashboard.json`
|
|
||||||
* - `./locales/vi/dashboard.json`
|
|
||||||
* - `./locales/en/sessions.json`
|
|
||||||
* - `./locales/vi/sessions.json`
|
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -57,44 +52,30 @@
|
|||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import i18n from "i18next";
|
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import common_en from "./locales/en/common.json";
|
// Every locale file is its own lazy chunk (vite.config.ts groups them into one
|
||||||
import common_vi from "./locales/vi/common.json";
|
// chunk per language), so a page loads only the language it shows. i18next
|
||||||
import nav_en from "./locales/en/nav.json";
|
// reads them through this inline backend: `init` fetches the detected language
|
||||||
import nav_vi from "./locales/vi/nav.json";
|
// (plus the `en` fallback), and `changeLanguage` fetches a new one BEFORE it
|
||||||
import dashboard_en from "./locales/en/dashboard.json";
|
// switches, so no raw key is ever painted.
|
||||||
import dashboard_vi from "./locales/vi/dashboard.json";
|
const localeFiles = import.meta.glob<Record<string, unknown>>("./locales/*/*.json", {
|
||||||
import sessions_en from "./locales/en/sessions.json";
|
import: "default",
|
||||||
import sessions_vi from "./locales/vi/sessions.json";
|
});
|
||||||
import activity_en from "./locales/en/activity.json";
|
const lazyLocales: BackendModule = {
|
||||||
import activity_vi from "./locales/vi/activity.json";
|
type: "backend",
|
||||||
import analytics_en from "./locales/en/analytics.json";
|
init() {},
|
||||||
import analytics_vi from "./locales/vi/analytics.json";
|
read(lng, ns, cb) {
|
||||||
import workflows_en from "./locales/en/workflows.json";
|
const load = localeFiles[`./locales/${lng}/${ns}.json`];
|
||||||
import workflows_vi from "./locales/vi/workflows.json";
|
if (!load) return cb(null, {});
|
||||||
import settings_en from "./locales/en/settings.json";
|
load().then(
|
||||||
import settings_vi from "./locales/vi/settings.json";
|
(data) => cb(null, data as ResourceKey),
|
||||||
import kanban_en from "./locales/en/kanban.json";
|
(err) => cb(err, false)
|
||||||
import kanban_vi from "./locales/vi/kanban.json";
|
);
|
||||||
import errors_en from "./locales/en/errors.json";
|
},
|
||||||
import errors_vi from "./locales/vi/errors.json";
|
};
|
||||||
import updates_en from "./locales/en/updates.json";
|
|
||||||
import updates_vi from "./locales/vi/updates.json";
|
|
||||||
import ccConfig_en from "./locales/en/ccConfig.json";
|
|
||||||
import ccConfig_vi from "./locales/vi/ccConfig.json";
|
|
||||||
import codexConfig_en from "./locales/en/codexConfig.json";
|
|
||||||
import codexConfig_vi from "./locales/vi/codexConfig.json";
|
|
||||||
import run_en from "./locales/en/run.json";
|
|
||||||
import run_vi from "./locales/vi/run.json";
|
|
||||||
import alerts_en from "./locales/en/alerts.json";
|
|
||||||
import alerts_vi from "./locales/vi/alerts.json";
|
|
||||||
import splash_en from "./locales/en/splash.json";
|
|
||||||
import splash_vi from "./locales/vi/splash.json";
|
|
||||||
import lanes_en from "./locales/en/lanes.json";
|
|
||||||
import lanes_vi from "./locales/vi/lanes.json";
|
|
||||||
|
|
||||||
/** Keep `<html lang>` on the active language, so a screen reader speaks the
|
/** Keep `<html lang>` on the active language, so a screen reader speaks the
|
||||||
* page with the right voice and the browser hyphenates/translates correctly.
|
* page with the right voice and the browser hyphenates/translates correctly.
|
||||||
@@ -105,50 +86,13 @@ export function syncDocumentLang(): void {
|
|||||||
}
|
}
|
||||||
i18n.on("languageChanged", syncDocumentLang);
|
i18n.on("languageChanged", syncDocumentLang);
|
||||||
|
|
||||||
i18n
|
/** Resolves once the detected language's namespaces are loaded; `main.tsx`
|
||||||
|
* awaits it before the first render. */
|
||||||
|
export const i18nReady = i18n
|
||||||
|
.use(lazyLocales)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources: {
|
|
||||||
en: {
|
|
||||||
common: common_en,
|
|
||||||
nav: nav_en,
|
|
||||||
dashboard: dashboard_en,
|
|
||||||
sessions: sessions_en,
|
|
||||||
activity: activity_en,
|
|
||||||
analytics: analytics_en,
|
|
||||||
workflows: workflows_en,
|
|
||||||
settings: settings_en,
|
|
||||||
kanban: kanban_en,
|
|
||||||
errors: errors_en,
|
|
||||||
updates: updates_en,
|
|
||||||
ccConfig: ccConfig_en,
|
|
||||||
codexConfig: codexConfig_en,
|
|
||||||
run: run_en,
|
|
||||||
alerts: alerts_en,
|
|
||||||
splash: splash_en,
|
|
||||||
lanes: lanes_en,
|
|
||||||
},
|
|
||||||
vi: {
|
|
||||||
common: common_vi,
|
|
||||||
nav: nav_vi,
|
|
||||||
dashboard: dashboard_vi,
|
|
||||||
sessions: sessions_vi,
|
|
||||||
activity: activity_vi,
|
|
||||||
analytics: analytics_vi,
|
|
||||||
workflows: workflows_vi,
|
|
||||||
settings: settings_vi,
|
|
||||||
kanban: kanban_vi,
|
|
||||||
errors: errors_vi,
|
|
||||||
updates: updates_vi,
|
|
||||||
ccConfig: ccConfig_vi,
|
|
||||||
codexConfig: codexConfig_vi,
|
|
||||||
run: run_vi,
|
|
||||||
alerts: alerts_vi,
|
|
||||||
splash: splash_vi,
|
|
||||||
lanes: lanes_vi,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
supportedLngs: ["en", "vi"],
|
supportedLngs: ["en", "vi"],
|
||||||
nonExplicitSupportedLngs: true,
|
nonExplicitSupportedLngs: true,
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
@@ -182,5 +126,5 @@ i18n
|
|||||||
|
|
||||||
syncDocumentLang();
|
syncDocumentLang();
|
||||||
|
|
||||||
/** Configured i18next instance — import side effects run {@link init}. */
|
/** Configured i18next instance — import side effects start {@link init}. */
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -7,20 +7,26 @@
|
|||||||
"event_pattern": "Event pattern",
|
"event_pattern": "Event pattern",
|
||||||
"inactivity": "Inactivity",
|
"inactivity": "Inactivity",
|
||||||
"status_duration": "Stuck agent",
|
"status_duration": "Stuck agent",
|
||||||
"token_threshold": "Token threshold"
|
"token_threshold": "Token threshold",
|
||||||
|
"cost_threshold": "Cost threshold",
|
||||||
|
"budget": "Spend budget"
|
||||||
},
|
},
|
||||||
"ruleTypeHints": {
|
"ruleTypeHints": {
|
||||||
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
||||||
"inactivity": "Fires when an active session has received no events for the configured number of minutes.",
|
"inactivity": "Fires when an active session has received no events for the configured number of minutes.",
|
||||||
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
||||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold."
|
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
||||||
|
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
||||||
|
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
|
||||||
},
|
},
|
||||||
"ruleDesc": {
|
"ruleDesc": {
|
||||||
"eventPattern": "Matches {{pattern}}",
|
"eventPattern": "Matches {{pattern}}",
|
||||||
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
|
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
|
||||||
"inactivity": "No session activity for {{minutes}} min",
|
"inactivity": "No session activity for {{minutes}} min",
|
||||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||||
"tokenThreshold": "Session tokens exceed {{tokens}}"
|
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||||
|
"costThreshold": "Session cost reaches {{usd}}",
|
||||||
|
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Alert rules",
|
"title": "Alert rules",
|
||||||
@@ -48,7 +54,13 @@
|
|||||||
"agentStatus": "Agent status",
|
"agentStatus": "Agent status",
|
||||||
"minutes": "Minutes",
|
"minutes": "Minutes",
|
||||||
"totalTokens": "Total tokens",
|
"totalTokens": "Total tokens",
|
||||||
"cooldown": "Cooldown (seconds)"
|
"cooldown": "Cooldown (seconds)",
|
||||||
|
"usd": "Cost (USD)",
|
||||||
|
"period": "Period",
|
||||||
|
"limitUsd": "Limit (USD)",
|
||||||
|
"thresholds": "Alert at (%)",
|
||||||
|
"budgetTool": "AI tool",
|
||||||
|
"allTools": "All AI tools"
|
||||||
},
|
},
|
||||||
"deleteTitle": "Delete alert rule?",
|
"deleteTitle": "Delete alert rule?",
|
||||||
"help": {
|
"help": {
|
||||||
@@ -63,8 +75,19 @@
|
|||||||
"minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).",
|
"minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).",
|
||||||
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
|
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
|
||||||
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
|
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
|
||||||
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown."
|
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown.",
|
||||||
|
"usd": "A session reaching this many US dollars fires the alert.",
|
||||||
|
"period": "Day starts at your local midnight, week on Monday, month on the 1st.",
|
||||||
|
"limitUsd": "The spend limit for one period, in US dollars.",
|
||||||
|
"thresholds": "Percents of the limit, comma-separated, e.g. 50, 80, 100. Each fires once per period.",
|
||||||
|
"budgetTool": "Count only this AI tool's sessions."
|
||||||
},
|
},
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewing": "Checking…",
|
||||||
|
"previewHint": "What this rule would have fired over the last 7 days — nothing is saved or sent",
|
||||||
|
"previewResult": "Would have fired {{fires}} times in {{sessions}} sessions over the last {{days}} days.",
|
||||||
|
"previewBudget": "Spend per period (this one and the five before it):",
|
||||||
|
"previewUnsupported": "This rule type cannot be replayed from history.",
|
||||||
"edit": "Edit rule",
|
"edit": "Edit rule",
|
||||||
"save": "Save rule",
|
"save": "Save rule",
|
||||||
"typeFixed": "A rule's type cannot be changed — create a new rule instead.",
|
"typeFixed": "A rule's type cannot be changed — create a new rule instead.",
|
||||||
@@ -81,6 +104,11 @@
|
|||||||
"emptyDescription": "Alerts fired by your rules will appear here in real time.",
|
"emptyDescription": "Alerts fired by your rules will appear here in real time.",
|
||||||
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
|
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
|
||||||
},
|
},
|
||||||
|
"periods": {
|
||||||
|
"day": "day",
|
||||||
|
"week": "week",
|
||||||
|
"month": "month"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"loadRules": "Could not load alert rules: {{error}}",
|
"loadRules": "Could not load alert rules: {{error}}",
|
||||||
"loadAlerts": "Could not load alerts: {{error}}",
|
"loadAlerts": "Could not load alerts: {{error}}",
|
||||||
|
|||||||
@@ -51,6 +51,56 @@
|
|||||||
"last7Days": "Last 7 days",
|
"last7Days": "Last 7 days",
|
||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
|
"toolFilter": "AI tool",
|
||||||
|
"allTools": "All AI tools",
|
||||||
|
"costByTool": "Cost by AI tool",
|
||||||
|
"spendPace": "Spend pace",
|
||||||
|
"burnRate": "Burn rate (7-day avg / day)",
|
||||||
|
"burnRateHint": "Average cost per day over the last 7 days, today included",
|
||||||
|
"monthToDate": "Month to date",
|
||||||
|
"forecastMonth": "Forecast for this month",
|
||||||
|
"forecastHint": "Month to date plus the 7-day pace for each day left",
|
||||||
|
"budgets": "Budgets",
|
||||||
|
"noBudgets": "No spend budget yet.",
|
||||||
|
"addBudget": "Add one in Settings → Notifications",
|
||||||
|
"projected": "projected",
|
||||||
|
"projectedHint": "This period's spend at the current pace, carried to its end",
|
||||||
|
"range": "Date range",
|
||||||
|
"rangePreset": {
|
||||||
|
"today": "Today",
|
||||||
|
"week": "This week",
|
||||||
|
"month": "This month",
|
||||||
|
"7d": "Last 7 days",
|
||||||
|
"30d": "Last 30 days",
|
||||||
|
"90d": "Last 90 days",
|
||||||
|
"all": "All time",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"rangeFrom": "From",
|
||||||
|
"rangeTo": "To",
|
||||||
|
"query": {
|
||||||
|
"entities": {
|
||||||
|
"agents": "Agents"
|
||||||
|
},
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Search text…",
|
||||||
|
"status": "Status",
|
||||||
|
"allStatuses": "All statuses",
|
||||||
|
"cwd": "Folder",
|
||||||
|
"cwdPlaceholder": "/path/to/project",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"sortBy": "Sort by {{column}}",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"exportJson": "Export JSON",
|
||||||
|
"exportNote": "An export holds the whole filter, up to {{cap}} rows.",
|
||||||
|
"exportTruncated": "{{total}} rows match; the export holds the first {{cap}}.",
|
||||||
|
"rows": "{{from}}–{{to}} of {{total}}",
|
||||||
|
"prev": "Previous page",
|
||||||
|
"next": "Next page",
|
||||||
|
"empty": "Nothing matches these filters.",
|
||||||
|
"reset": "Reset filters"
|
||||||
|
},
|
||||||
"tokensExCacheReads": "Tokens (excl. cache reads)",
|
"tokensExCacheReads": "Tokens (excl. cache reads)",
|
||||||
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
|
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
|
||||||
"avgPerWeekday30d": "Average per weekday, last 30 days"
|
"avgPerWeekday30d": "Average per weekday, last 30 days"
|
||||||
|
|||||||
@@ -263,7 +263,8 @@
|
|||||||
"title": "Claude Code profiles",
|
"title": "Claude Code profiles",
|
||||||
"openButton": "Switch profile",
|
"openButton": "Switch profile",
|
||||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||||
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
|
||||||
|
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
|
||||||
"activeLabel": "Logged in now",
|
"activeLabel": "Logged in now",
|
||||||
"noActive": "No Claude account is logged in on this machine.",
|
"noActive": "No Claude account is logged in on this machine.",
|
||||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||||
|
|||||||
@@ -202,5 +202,32 @@
|
|||||||
"message": "Your edits have not been saved and will be lost.",
|
"message": "Your edits have not been saved and will be lost.",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"keep": "Keep editing"
|
"keep": "Keep editing"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Sign in to AgentHub",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"submitting": "Signing in…",
|
||||||
|
"failed": "Wrong username or password",
|
||||||
|
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"help": {
|
||||||
|
"sessions": "A session is one conversation with an AI agent, from start to finish.",
|
||||||
|
"agents": "Agents are the AI workers: the main one in each session plus any helpers it started.",
|
||||||
|
"tokens": "Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost.",
|
||||||
|
"cost": "Estimated money spent, calculated from token counts and each model's price.",
|
||||||
|
"events": "Every step the AI takes (running a tool, finishing a reply…) is recorded as one event.",
|
||||||
|
"tokenMix": "Input = what you and the files sent the AI. Output = what the AI wrote. Cache read = text reused from memory (about 10× cheaper). Cache write = text saved to memory for reuse.",
|
||||||
|
"spendPace": "How fast money is being spent, and where this month will end up if that speed continues.",
|
||||||
|
"toolCalls": "How many actions the AI took: reading a file, editing, running a command…",
|
||||||
|
"subagents": "Helper agents the main AI started to work on part of the task in parallel.",
|
||||||
|
"compactions": "When a conversation gets too long, it is summarized to make room. Early details can be lost.",
|
||||||
|
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||||
|
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,8 +215,8 @@
|
|||||||
"proof.ticketReport": "Task report",
|
"proof.ticketReport": "Task report",
|
||||||
"renameLabel": "Rename this workspace",
|
"renameLabel": "Rename this workspace",
|
||||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||||
"router.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"router.label": "AI Router",
|
"proxy.label": "AI Proxy",
|
||||||
"runtime.boot": "▶ up",
|
"runtime.boot": "▶ up",
|
||||||
"runtime.busy.down": "stopping…",
|
"runtime.busy.down": "stopping…",
|
||||||
"runtime.busy.up": "booting…",
|
"runtime.busy.up": "booting…",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"activityFeed": "Activity Feed",
|
"activityFeed": "Activity Feed",
|
||||||
"agentBoard": "Kanban Board",
|
"agentBoard": "Kanban Board",
|
||||||
|
"agents": "Agents",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"avg": "avg",
|
"avg": "avg",
|
||||||
"brand": "AgentHub",
|
"brand": "AgentHub",
|
||||||
@@ -67,6 +68,8 @@
|
|||||||
"today": "Today {{cost}}",
|
"today": "Today {{cost}}",
|
||||||
"workspace": "Workspace {{cost}}",
|
"workspace": "Workspace {{cost}}",
|
||||||
"session": "Session {{cost}}",
|
"session": "Session {{cost}}",
|
||||||
|
"budget": "Budget {{pct}}%",
|
||||||
|
"budgetTitle": "{{name}}: {{spend}} of {{limit}} this period",
|
||||||
"tokens": "({{tokens}} Tokens)",
|
"tokens": "({{tokens}} Tokens)",
|
||||||
"quotaUsed": "{{pct}}% used",
|
"quotaUsed": "{{pct}}% used",
|
||||||
"quotaTitle": "Usage",
|
"quotaTitle": "Usage",
|
||||||
@@ -76,14 +79,19 @@
|
|||||||
"quotaRefreshing": "Refreshing…",
|
"quotaRefreshing": "Refreshing…",
|
||||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||||
"quotaFailed": "Could not read the quota",
|
"quotaFailed": "Could not read the quota",
|
||||||
|
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||||
"quotaActive": "in use",
|
"quotaActive": "in use",
|
||||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||||
"quotaSignInExpired": "Sign-in expired",
|
"quotaSignInExpired": "Sign-in expired",
|
||||||
"quotaWindow": { "wk": "Week", "mo": "Month" }
|
"quotaWindow": {
|
||||||
|
"wk": "Week",
|
||||||
|
"mo": "Month"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
|
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"now": "now"
|
"now": "now"
|
||||||
|
|||||||
@@ -142,11 +142,10 @@
|
|||||||
"output": "Output"
|
"output": "Output"
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Drop image to attach",
|
"hint": "Drop files or images to attach",
|
||||||
"uploading": "Uploading image…",
|
"uploading": "Uploading…",
|
||||||
"failed": "Could not attach the image",
|
"failed": "Could not attach the file",
|
||||||
"imagesOnly": "Only images can be dropped here",
|
"remove": "Remove attachment",
|
||||||
"remove": "Remove image",
|
|
||||||
"view": "View image"
|
"view": "View image"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
|
|||||||
@@ -54,6 +54,55 @@
|
|||||||
"copyId": "Copy session ID",
|
"copyId": "Copy session ID",
|
||||||
"copyFolder": "Copy folder path"
|
"copyFolder": "Copy folder path"
|
||||||
},
|
},
|
||||||
|
"replay": {
|
||||||
|
"tab": "Replay",
|
||||||
|
"loading": "Loading events…",
|
||||||
|
"label": "Session replay",
|
||||||
|
"truncated": "Replaying the newest {{shown}} of {{total}} events.",
|
||||||
|
"start": "Jump to start (Home)",
|
||||||
|
"back": "Step back (←)",
|
||||||
|
"play": "Play (Space)",
|
||||||
|
"pause": "Pause (Space)",
|
||||||
|
"forward": "Step forward (→)",
|
||||||
|
"end": "Jump to end (End)",
|
||||||
|
"speed": "Playback speed",
|
||||||
|
"position": "{{at}} / {{n}}",
|
||||||
|
"scrubber": "Event position",
|
||||||
|
"minimap": "Lifecycle markers",
|
||||||
|
"marker": {
|
||||||
|
"SessionStart": "Session start",
|
||||||
|
"UserPromptSubmit": "Prompt",
|
||||||
|
"Compaction": "Compaction",
|
||||||
|
"SubagentStop": "Subagent done",
|
||||||
|
"APIError": "API error",
|
||||||
|
"Stop": "Turn end"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"status": "Status",
|
||||||
|
"tool": "Running tool",
|
||||||
|
"prompts": "Prompts",
|
||||||
|
"errors": "Errors",
|
||||||
|
"compactions": "Compactions",
|
||||||
|
"elapsed": "Elapsed",
|
||||||
|
"toolCounts": "Tool calls so far"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"create": "Snapshot",
|
||||||
|
"hint": "Freeze this session into a read-only copy with a share link",
|
||||||
|
"title": "Create a snapshot",
|
||||||
|
"body": "Freezes this session as it is now — its agents, events, stats and cost — into a read-only copy under a private link. Later activity does not change it, and it survives Clear Data.",
|
||||||
|
"redactPaths": "Redact file paths (working directory, transcript and file paths)",
|
||||||
|
"confirm": "Create and copy link",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"open": "Open snapshot",
|
||||||
|
"notFound": "Snapshot not found",
|
||||||
|
"banner": "Snapshot taken {{date}}",
|
||||||
|
"bannerBody": "A read-only copy of the session as it was then. Nothing here updates.",
|
||||||
|
"redacted": "Paths redacted",
|
||||||
|
"openLive": "Open live session",
|
||||||
|
"timeline": "Timeline ({{n}})"
|
||||||
|
},
|
||||||
"exportCsv": "CSV",
|
"exportCsv": "CSV",
|
||||||
"exportJson": "JSON",
|
"exportJson": "JSON",
|
||||||
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
|
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
|
||||||
|
|||||||
@@ -303,6 +303,14 @@
|
|||||||
"typeToConfirm": "Type “{{phrase}}” to confirm:",
|
"typeToConfirm": "Type “{{phrase}}” to confirm:",
|
||||||
"phrase": "clear all data"
|
"phrase": "clear all data"
|
||||||
},
|
},
|
||||||
|
"autostart": {
|
||||||
|
"title": "Startup",
|
||||||
|
"label": "Start AgentHub automatically",
|
||||||
|
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||||
|
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||||
|
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||||
|
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||||
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"description": "Server runtime information.",
|
"description": "Server runtime information.",
|
||||||
@@ -561,16 +569,21 @@
|
|||||||
"deleteAndPurge": "Remove + delete data",
|
"deleteAndPurge": "Remove + delete data",
|
||||||
"removeKeep": "Remove (keep data)",
|
"removeKeep": "Remove (keep data)",
|
||||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||||
|
"fieldSyncCodex": "Also pull Codex sessions",
|
||||||
|
"fieldRemoteCodexHome": "Remote Codex home (optional)",
|
||||||
|
"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.",
|
||||||
|
"codexOn": "+ Codex",
|
||||||
|
"syncCodex": "{{n}} Codex",
|
||||||
"fieldLabelPlaceholder": "Dev box"
|
"fieldLabelPlaceholder": "Dev box"
|
||||||
},
|
},
|
||||||
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
|
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
|
||||||
"aiRouter": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"title": "AI Proxy",
|
||||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||||
"colLabel": "Profile",
|
"colLabel": "Profile",
|
||||||
"colBaseUrl": "Endpoint",
|
"colBaseUrl": "Endpoint",
|
||||||
@@ -579,16 +592,16 @@
|
|||||||
"keySet": "Saved",
|
"keySet": "Saved",
|
||||||
"keyMissing": "None",
|
"keyMissing": "None",
|
||||||
"fieldLabel": "Name",
|
"fieldLabel": "Name",
|
||||||
"fieldLabelHint": "What you will see in the workspace form. Free text.",
|
"fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
|
||||||
"fieldBaseUrl": "Base URL",
|
"fieldBaseUrl": "Base URL",
|
||||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
"fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
|
||||||
"fieldApiKey": "API key",
|
"fieldApiKey": "API key",
|
||||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||||
"fieldModel": "Model",
|
"fieldModel": "Model",
|
||||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
"fieldModelHint": "Used for every tier unless you override one below. Many proxies accept one symbolic name.",
|
||||||
"fieldBehavesAs": "Behaves as (Claude Code)",
|
"fieldBehavesAs": "Behaves as (Claude Code)",
|
||||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||||
"perTier": "Per-tier overrides",
|
"perTier": "Per-tier overrides",
|
||||||
"perTierHint": "Leave a field blank to use the model above.",
|
"perTierHint": "Leave a field blank to use the model above.",
|
||||||
"tier": {
|
"tier": {
|
||||||
@@ -600,12 +613,12 @@
|
|||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
"removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
|
||||||
"loading": "Loading profiles…",
|
"loading": "Loading profiles…",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"testing": "Testing…",
|
"testing": "Testing…",
|
||||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
"testOk": "answered — {{model}} is reachable with this key.",
|
||||||
"testFailed": "No answer ({{status}}): {{message}}"
|
"testFailed": "no answer ({{status}}): {{message}}"
|
||||||
},
|
},
|
||||||
"aiTools": {
|
"aiTools": {
|
||||||
"title": "AI Tools",
|
"title": "AI Tools",
|
||||||
@@ -687,7 +700,7 @@
|
|||||||
},
|
},
|
||||||
"codexHome": {
|
"codexHome": {
|
||||||
"title": "CODEX_HOME",
|
"title": "CODEX_HOME",
|
||||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
|
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
|
||||||
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
|
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||||
"placeholder": "/path/to/codex-home"
|
"placeholder": "/path/to/codex-home"
|
||||||
},
|
},
|
||||||
@@ -779,8 +792,8 @@
|
|||||||
"tool": {
|
"tool": {
|
||||||
"description": "{{tool}}: path, usage, default model and config files."
|
"description": "{{tool}}: path, usage, default model and config files."
|
||||||
},
|
},
|
||||||
"aiRouter": {
|
"aiProxy": {
|
||||||
"title": "AI Router",
|
"title": "AI Proxy",
|
||||||
"description": "Endpoints that stand in front of Claude Code and Codex."
|
"description": "Endpoints that stand in front of Claude Code and Codex."
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -802,6 +815,18 @@
|
|||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"description": "Server version, uptime and runtime."
|
"description": "Server version, uptime and runtime."
|
||||||
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"title": "Snapshots",
|
||||||
|
"description": "Frozen, read-only copies of sessions, each behind its own link."
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Reports",
|
||||||
|
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Accounts, their usage and the audit log."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backPrevious": "Back",
|
"backPrevious": "Back",
|
||||||
@@ -839,6 +864,79 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable"
|
"disable": "Disable"
|
||||||
},
|
},
|
||||||
|
"snapshots": {
|
||||||
|
"title": "Session snapshots",
|
||||||
|
"description": "Created from a session's page with Snapshot. They survive Clear Data; delete them here.",
|
||||||
|
"empty": "No snapshots yet.",
|
||||||
|
"events": "{{n}} events",
|
||||||
|
"redacted": "Paths redacted",
|
||||||
|
"open": "Open",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteTitle": "Delete this snapshot?",
|
||||||
|
"deleteBody": "Its link stops working. The session itself is not touched.",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"pushIngest": {
|
||||||
|
"title": "Push ingest",
|
||||||
|
"description": "For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.",
|
||||||
|
"on": "On — a token exists",
|
||||||
|
"off": "Off — no token yet",
|
||||||
|
"create": "Create token",
|
||||||
|
"rotate": "Rotate token",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"rotateConfirm": "Make a new token? The current one stops working at once.",
|
||||||
|
"revokeConfirm": "Revoke the token and turn push ingest off?",
|
||||||
|
"shownOnce": "Copy this token now: only its hash is stored, so it is never shown again.",
|
||||||
|
"copy": "Copy",
|
||||||
|
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 is set by the environment the dashboard started in; change it there.",
|
||||||
|
"reachHint": "The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:",
|
||||||
|
"limits": "At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not."
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy",
|
||||||
|
"description": "What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.",
|
||||||
|
"redact": "Redact secrets before storing",
|
||||||
|
"redactHint": "API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.",
|
||||||
|
"ignore": "Ignored folders",
|
||||||
|
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
||||||
|
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Scheduled reports",
|
||||||
|
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
|
||||||
|
"add": "New report",
|
||||||
|
"defaultName": "Weekly summary",
|
||||||
|
"empty": "No scheduled report yet.",
|
||||||
|
"name": "Name",
|
||||||
|
"windowDays": "Days covered",
|
||||||
|
"frequency": "Frequency",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"weekday": "Day",
|
||||||
|
"hour": "Time",
|
||||||
|
"windowHint": "A report covers the full days before the day it runs, at this browser's time zone.",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"runNow": "Run now",
|
||||||
|
"history": "History",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"everyDay": "Every day at {{time}}",
|
||||||
|
"everyWeek": "Every {{day}} at {{time}}",
|
||||||
|
"window_one": "last {{count}} day",
|
||||||
|
"window_other": "last {{count}} days",
|
||||||
|
"lastRun": "Last run {{at}}",
|
||||||
|
"noRuns": "No runs yet.",
|
||||||
|
"trigger": {
|
||||||
|
"schedule": "Scheduled",
|
||||||
|
"manual": "Manual"
|
||||||
|
},
|
||||||
|
"download": "Download {{format}}",
|
||||||
|
"deleteTitle": "Delete this report?",
|
||||||
|
"deleteBody": "Its schedule, its run history and the files it wrote are deleted."
|
||||||
|
},
|
||||||
"prefsBackup": {
|
"prefsBackup": {
|
||||||
"title": "This browser's preferences",
|
"title": "This browser's preferences",
|
||||||
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||||
@@ -848,5 +946,21 @@
|
|||||||
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
||||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||||
"badFile": "That file is not an AgentHub preferences file."
|
"badFile": "That file is not an AgentHub preferences file."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||||
|
"name": "User",
|
||||||
|
"role": "Role",
|
||||||
|
"workspaces": "Workspaces",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"last7d": "7 days",
|
||||||
|
"last30d": "30 days",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"tokens": "{{n}} tokens",
|
||||||
|
"manageHint": "Add, disable or change a user on the host: agenthub users …",
|
||||||
|
"auditTitle": "Audit log",
|
||||||
|
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
|
||||||
|
"auditEmpty": "No reads recorded yet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user