From 08437179a3a1cce2ed0d15b6a177c307bae6e729 Mon Sep 17 00:00:00 2001 From: Yad Konrad Date: Fri, 3 Jul 2026 11:39:55 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(sync):=20two-way=20multi-device=20sync?= =?UTF-8?q?=20=E2=80=94=20pull=20+=20union=20overlay=20+=20v029=20(#100=20?= =?UTF-8?q?Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/multi-device-sync.md | 12 +- docs/specs/session-schema-v1.md | 5 +- stackunderflow/cli.py | 48 +++ stackunderflow/routes/sync.py | 132 ++++++++ stackunderflow/server.py | 4 + .../migrations/v029_sync_pull_landing.sql | 153 +++++++++ stackunderflow/store/schema.py | 8 +- stackunderflow/sync/__init__.py | 10 +- stackunderflow/sync/merge.py | 290 ++++++++++++++++ stackunderflow/sync/runner.py | 275 +++++++++++++++ stackunderflow/sync/serialize.py | 16 + .../stackunderflow/routes/test_sync_route.py | 164 +++++++++ .../store/test_migration_v028.py | 7 +- .../store/test_migration_v029.py | 124 +++++++ tests/stackunderflow/sync/test_cli_sync.py | 77 +++++ tests/stackunderflow/sync/test_merge.py | 189 +++++++++++ tests/stackunderflow/sync/test_pull.py | 312 ++++++++++++++++++ 17 files changed, 1814 insertions(+), 12 deletions(-) create mode 100644 stackunderflow/routes/sync.py create mode 100644 stackunderflow/store/migrations/v029_sync_pull_landing.sql create mode 100644 stackunderflow/sync/merge.py create mode 100644 tests/stackunderflow/routes/test_sync_route.py create mode 100644 tests/stackunderflow/store/test_migration_v029.py create mode 100644 tests/stackunderflow/sync/test_merge.py create mode 100644 tests/stackunderflow/sync/test_pull.py diff --git a/docs/specs/multi-device-sync.md b/docs/specs/multi-device-sync.md index c6e7888..2adf490 100644 --- a/docs/specs/multi-device-sync.md +++ b/docs/specs/multi-device-sync.md @@ -1,6 +1,6 @@ # Multi-Device Sync — opt-in, client-side-encrypted, bring-your-own bucket -**Status:** Design spec (issue #100, Spec 28 — `needs-design`, `size-xl`, wave-6). No code yet. +**Status:** Phases 1–2 shipped (issue #100, Spec 28 — `size-xl`, wave-6). Phase 1 (one-way encrypted backup, schema v028) and Phase 2 (two-way multi-device read, schema v029) are implemented under `stackunderflow/sync/` + `routes/sync.py`; Phases 3–4 remain design. **Audience:** maintainer; anyone implementing cross-device sync. **Scope:** aggregate one person's several machines (laptop + work box + dev container) into one analytics view, by pushing **client-side-encrypted aggregates** to the user's **own** S3-compatible bucket. Zero-knowledge: the bucket stores ciphertext only, no StackUnderflow-hosted service exists, and **raw transcripts never leave the machine**. **Unblocks the issue's gate:** issue #100 stays `needs-design` "until `docs/specs/sync-protocol-v1.md` lands." This document is that spec (filename `docs/specs/multi-device-sync.md`). @@ -309,11 +309,15 @@ Each phase is independently useful and shippable. **Phase 0 — design.** This document. Satisfies the issue's `needs-design` gate. -**Phase 1 — MVP: one-way, encrypted backup-to-bucket.** +**Phase 1 — MVP: one-way, encrypted backup-to-bucket. (SHIPPED — schema v028.)** `sync init`, `sync push`, `sync status`. Encrypt the Overview/Cost-core marts → the user's own prefix. No pull, no merge. Delivers an **off-site, zero-knowledge encrypted backup of your aggregates** and exercises the whole stack — keys, `age`, `ObjectStore`, canonical serialization, outbox, manifest commit — end to end at minimum risk. New module `stackunderflow/sync/`: `keys.py`, `cipher.py`, `bucket.py`, `serialize.py`, `runner.py`; the `sync` Click group beside `backup` in `cli.py`; the additive migration (`sync_identity`, `sync_outbox`). -**Phase 2 — two-way: multi-device read.** -`sync pull` + `sync/merge.py` union overlay + `sync_cursors` + `sync_remote_devices` + `_remote` tables + the `?scope=all-devices` read path and `GET /api/sync/status`. This is the issue's headline goal: laptop + work + dev-container in one analytics view. +**Phase 2 — two-way: multi-device read. (SHIPPED — schema v029.)** +`sync pull` + `sync/merge.py` union overlay + `sync_cursors` + `sync_remote_devices` + the five `_remote` landing tables + the `?scope=all-devices` read path and `routes/sync.py` (`GET /api/sync/status` + `GET /api/sync/overview`). This is the issue's headline goal: laptop + work + dev-container in one analytics view. + +`sync pull` LISTs every *other* device's prefix (skipping our own), fetches + decrypts each `manifest.age`, enforces the monotonic-generation replay guard (§3.4), and downloads only shards whose content-hash moved since the last pull — **idempotent: an unchanged peer downloads nothing** (only the tiny per-device manifest, the commit point, is re-read). Each shard's plaintext hash is re-verified before it REPLACE-lands into `_remote` (month-scoped, so re-ingesting one month never wipes a device's others) and its `sync_cursors` row advances. Pull is strictly **read-only against the bucket** — it never PUTs to any prefix — and never writes `usage_events` / `price_book` / transcripts. `sync/merge.py` then overlays `local (JOIN projects for slug) UNION ALL _remote`, SUMming at the stable `(provider, slug, …)` grain, and dedups `session_mart` by the globally-unique `session_id` (deterministic local-then-lowest-device tiebreak) into a `merge_warnings` counter (§5.3). + +**Read surface — default-off, byte-identical.** The merged view is opt-in behind an explicit **`?scope=all-devices`**; the default `this-device` scope runs no union at all, so the existing dashboard path is unchanged and off the mart `<100ms` fast-path. `GET /api/sync/overview?scope=all-devices` returns the merged totals / per-day trend / per-project / per-provider-day / per-device breakdown + `merge_warnings`; `GET /api/sync/status` reports local config, known peers, and whether cross-device data is available. CLI: `stackunderflow sync pull` (add `--json` for a scriptable envelope); the merged dashboard read is then live at `/api/sync/overview?scope=all-devices`. **Phase 3 — daemon, pruning, hardening.** `sync auto --enable` — a daemon-thread continuous push modeled on `etl/watcher.py` (`watchfiles`, debounce) under a single-instance lock modeled on `etl/lock.py`. Retention: prune shards older than `--keep-months` **after** merge confirmation; GC orphan shards not referenced by the current manifest. Optional opaque-manifest layout (§4.4). diff --git a/docs/specs/session-schema-v1.md b/docs/specs/session-schema-v1.md index 8ad2e78..0c96770 100644 --- a/docs/specs/session-schema-v1.md +++ b/docs/specs/session-schema-v1.md @@ -1,6 +1,6 @@ # Session Schema v1 — open exchange format for AI coding sessions -**Status:** v1 (pinned to `schema_version = 28`). +**Status:** v1 (pinned to `schema_version = 29`). **Audience:** anyone writing a tool that wants to read from, or write to, the StackUnderflow store without reverse-engineering the SQL. **Scope:** the local SQLite schema at `~/.stackunderflow/store.db`. This document is the source of truth for the on-disk shape; the migrations under `stackunderflow/store/migrations/` (`.sql` DDL and `.py` data migrations) are the reference implementation. @@ -20,7 +20,7 @@ The schema described here is **additive-only**. Any future column requires a new ## Schema version -Pin to `schema_version = 28`. The current migration set is: +Pin to `schema_version = 29`. The current migration set is: | version | file | what it adds | |---|---|---| @@ -51,6 +51,7 @@ Pin to `schema_version = 28`. The current migration set is: | 26 | `v026_reasoning_tokens.sql` | adds `usage_events.reasoning_tokens` (reasoning/"thinking" attribution — an additive-metadata SUBSET of `output_tokens`, never priced; `DEFAULT 0`) | | 27 | `v027_worktree_of.sql` | adds `projects.worktree_of` (nullable parent-project slug for git-worktree fragment projects; NULL = normal project — campaign #8) | | 28 | `v028_sync_identity_outbox.sql` | `sync_identity` + `sync_outbox` (opt-in multi-device sync — device identity + per-shard push watermark; #100 Phase 1) | +| 29 | `v029_sync_pull_landing.sql` | `sync_cursors` + `sync_remote_devices` + five `_remote` landing tables (opt-in multi-device sync — pull watermarks + remote aggregate landing for the cross-device merge; #100 Phase 2) | Version 15 was reserved during planning and never created — the sequence skips from 14 to 16 by design. The migration runner keys on the leading `vNNN`, so the gap is harmless. diff --git a/stackunderflow/cli.py b/stackunderflow/cli.py index 0515645..4c5174a 100644 --- a/stackunderflow/cli.py +++ b/stackunderflow/cli.py @@ -1365,6 +1365,54 @@ def sync_push(): click.echo(f" Generation {result.generation}. Manifest committed.") +@sync_group.command("pull") +@click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON") +def sync_pull(as_json: bool): + """Fetch and merge every OTHER device's encrypted aggregates from your bucket. + + Reads each peer's prefix (never writes to it), downloads only the shards that + changed since the last pull, decrypts + verifies them, and lands them in the + local remote tables. The unified cross-device view is then available at + /api/sync/overview?scope=all-devices. Idempotent — an unchanged peer downloads + nothing. Exits non-zero on a hard failure (e.g. bucket unreachable) so it is + safe to script; per-peer/per-shard problems are reported as warnings, not fatal. + """ + if _sync_missing_deps(need_bucket=True): + click.echo(_SYNC_INSTALL_HINT) + sys.exit(1) + from stackunderflow.sync import runner + + conn = _open_store() + try: + if not runner.is_enabled(conn): + click.echo(" Sync is not configured. Run: stackunderflow sync init --bucket s3://your-bucket") + sys.exit(1) + try: + result = runner.run_pull(conn, state_dir=_STATE_DIR) + except Exception as exc: + click.echo(f" sync pull failed: {exc}") + sys.exit(1) + finally: + conn.close() + + if as_json: + click.echo(json.dumps(result.as_dict(), indent=2)) + return + + if result.devices_seen == 0: + click.echo(" No other devices found in the bucket yet.") + elif result.shards_ingested == 0: + click.echo(f" Up to date — {result.devices_seen} peer(s), nothing new to pull.") + else: + click.echo( + f" Pulled {result.shards_ingested} shard(s) from {result.devices_seen} peer(s); " + f"{result.skipped} unchanged." + ) + click.echo(" Merged view: /api/sync/overview?scope=all-devices") + for warning in result.warnings: + click.echo(f" warning: {warning}") + + @sync_group.command("status") @click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON") def sync_status(as_json: bool): diff --git a/stackunderflow/routes/sync.py b/stackunderflow/routes/sync.py new file mode 100644 index 0000000..6627ff5 --- /dev/null +++ b/stackunderflow/routes/sync.py @@ -0,0 +1,132 @@ +"""``/api/sync`` — opt-in multi-device sync status + the cross-device overview. + +Phase 2 read surface for ``docs/specs/multi-device-sync.md``. Two endpoints, both +read-only and both safe on a core install (no ``pyrage`` / ``boto3`` needed — they +read the local store and the already-landed ``_remote`` tables; decryption +happened earlier, in ``sync pull``). + +``GET /api/sync/status`` + Local sync config (device UUID, fingerprint, bucket, pending-upload count) + plus the known peers and whether any cross-device data has been pulled. Pure + local read; works whether sync is on or off. + +``GET /api/sync/overview?scope=`` + **Default ``this-device``** — returns a tiny "not merged" stub and runs **no** + union query, so this endpoint is off the mart ``<100ms`` fast-path and a store + with sync off behaves as if the feature were absent. Only ``?scope=all-devices`` + (and sync enabled) computes :func:`stackunderflow.sync.merge.merged_overview` + — the ``local UNION ALL _remote`` roll-up. Cost figures are pre-converted + into the active currency, matching every other cost endpoint's contract. + +This is a *new, additive* surface: no existing route or query changes, so the +default dashboard path stays byte-identical. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Query + +import stackunderflow.deps as deps +from stackunderflow.infra.currency import active_currency_payload +from stackunderflow.store import db +from stackunderflow.sync import merge, runner + +router = APIRouter() + +_SCOPE_QUERY = Query( + "this-device", + description="'all-devices' to union pulled peers; anything else = this-device-only (default)", +) + + +def _list_peers(conn: Any) -> list[dict]: + """Known peer devices from ``sync_remote_devices`` (empty until first pull).""" + rows = conn.execute( + "SELECT remote_device_uuid, alias, key_fingerprint, " + " first_seen, last_seen, last_generation " + "FROM sync_remote_devices ORDER BY remote_device_uuid" + ).fetchall() + return [dict(r) for r in rows] + + +def _apply_currency(payload: dict, currency: dict) -> None: + """Pre-convert every USD cost field in *payload* into the active currency. + + No-op at rate 1.0 (the default), so the merged figures stay in USD unless the + user configured a display currency — the frontend then never multiplies again. + """ + rate = currency["rate_from_usd"] + if rate == 1.0: + return + payload["totals"]["cost_usd"] = float(payload["totals"]["cost_usd"]) * rate + for row in payload["by_day"]: + row["cost_usd"] = float(row["cost_usd"]) * rate + for row in payload["by_project"]: + row["total_cost_usd"] = float(row["total_cost_usd"] or 0.0) * rate + for row in payload["by_provider_day"]: + row["cost_usd"] = float(row["cost_usd"] or 0.0) * rate + for row in payload["devices"]: + row["cost_usd"] = float(row["cost_usd"] or 0.0) * rate + + +@router.get("/api/sync/status") +async def get_sync_status() -> dict: + """Local sync config + peers + whether cross-device data is available. + + Purely local; never hits the network or a bucket and needs no optional deps. + """ + conn = db.connect(deps.store_path) + try: + status = runner.status(conn).as_dict() + peers = _list_peers(conn) + remote_rows = merge.remote_row_count(conn) + finally: + conn.close() + + status["peers"] = peers + status["peer_count"] = len(peers) + status["remote_rows"] = remote_rows + # The FE shows the all-devices toggle only when there is something to merge. + status["all_devices_available"] = bool(status["enabled"] and remote_rows > 0) + status["scanned_at"] = datetime.now(UTC).isoformat() + return status + + +@router.get("/api/sync/overview") +async def get_sync_overview(scope: str = _SCOPE_QUERY) -> dict: + """This-device stub by default; the merged cross-device roll-up on opt-in. + + ``?scope=all-devices`` (with sync enabled) returns the ``local UNION ALL + _remote`` overview — totals, per-day trend, per-project, per-provider-day, + a per-device breakdown, and the ``merge_warnings`` count. Any other scope, or + sync disabled, returns a minimal not-merged stub and runs no union. + """ + scope_str = scope if isinstance(scope, str) else "this-device" + + conn = db.connect(deps.store_path) + try: + enabled = runner.is_enabled(conn) + if scope_str != "all-devices" or not enabled: + # DEFAULT this-device path: no union runs — off the fast-path, and a + # sync-off store behaves exactly as if the feature were absent. + return { + "scope": "this-device", + "merged": False, + "sync_enabled": enabled, + "hint": "pass ?scope=all-devices to union pulled peers", + } + payload = merge.merged_overview(conn) + finally: + conn.close() + + currency = active_currency_payload() + _apply_currency(payload, currency) + payload["scope"] = "all-devices" + payload["merged"] = True + payload["sync_enabled"] = True + payload["currency"] = currency + payload["generated_at"] = datetime.now(UTC).isoformat() + return payload diff --git a/stackunderflow/server.py b/stackunderflow/server.py index 35f066c..b68c521 100644 --- a/stackunderflow/server.py +++ b/stackunderflow/server.py @@ -56,6 +56,9 @@ from stackunderflow.routes import ( static_analysis as static_analysis_routes, ) +from stackunderflow.routes import ( + sync as sync_routes, +) from stackunderflow.services.bookmark_service import BookmarkService from stackunderflow.services.pricing_service import PricingService from stackunderflow.services.qa_service import QAService @@ -295,6 +298,7 @@ def _watcher_conn() -> "object": app.include_router(benchmark.router) app.include_router(patterns.router) app.include_router(worktrees.router) +app.include_router(sync_routes.router) # #100 Phase 2 — opt-in multi-device sync read surface diff --git a/stackunderflow/store/migrations/v029_sync_pull_landing.sql b/stackunderflow/store/migrations/v029_sync_pull_landing.sql new file mode 100644 index 0000000..fee6ace --- /dev/null +++ b/stackunderflow/store/migrations/v029_sync_pull_landing.sql @@ -0,0 +1,153 @@ +-- v029: opt-in multi-device sync — pull cursors + remote landing tables (Phase 2). +-- +-- Phase 2 of ``docs/specs/multi-device-sync.md`` (two-way, multi-device READ): +-- ``sync pull`` fetches every *other* device's encrypted aggregate shards, and +-- the ``sync/merge.py`` union overlay surfaces them behind ``?scope=all-devices``. +-- This migration lays the tables that pull writes and merge reads: +-- +-- * ``sync_cursors`` — PULL watermark: per (remote device, shard) the +-- content-hash we last ingested, so an unchanged remote shard is skipped +-- (zero downloads — the mirror of ``sync_outbox`` on the push side). +-- * ``sync_remote_devices`` — known peers: alias, key fingerprint, first/last +-- seen, and the highest manifest ``generation`` we have accepted (the +-- monotonic-generation replay guard, §3.4). +-- * ``_remote`` — per-mart landing tables for the Overview/Cost +-- core (daily / provider_day / model_day / project / session). Each mirrors +-- its local mart's columns BUT replaces the machine-local ``project_id`` with +-- the stable ``(provider, slug)`` identity and adds a ``device_uuid`` +-- provenance column. Pull REPLACEs a device's rows for each changed shard; +-- merge UNIONs local + remote and SUMs at the stable grain (§5.1). +-- +-- Migration is **additive** — no existing table is touched, so a store with sync +-- disabled (no ``sync_identity`` row, no peers pulled) is byte-for-byte unchanged +-- and every existing query behaves exactly as before. Every CREATE is +-- ``IF NOT EXISTS``; the loader's ``_ADD_COLUMN_GUARDS`` entry +-- ``(29, ("sync_cursors", "remote_device_uuid"))`` makes a partial prior run +-- (tables present, ``user_version`` behind) bump the version without re-running +-- the body. The landing-table column order MUST match the serialized shard +-- columns (``sync/serialize.py`` ``_SPECS``); ``tests/.../sync`` pins that. + +BEGIN; + +-- ── pull watermark ──────────────────────────────────────────────────────────── + +-- Per remote device, per shard: the content-hash we last decrypted + landed. +-- Unchanged manifest hash ⇒ the shard download is skipped (idempotent pull). +CREATE TABLE IF NOT EXISTS sync_cursors ( + remote_device_uuid TEXT NOT NULL, + shard_key TEXT NOT NULL, -- "daily_mart.2026-07" + remote_content_hash TEXT NOT NULL, + pulled_at TEXT NOT NULL, + PRIMARY KEY (remote_device_uuid, shard_key) +); + +-- Known peer devices + human aliases ("work-mac", "dev-box"). ``last_generation`` +-- is the monotonic replay guard: a manifest whose generation is lower than this +-- is rejected (§3.4). Additive-only — a brand-new table, so adding the guard +-- column here alters nothing existing. +CREATE TABLE IF NOT EXISTS sync_remote_devices ( + remote_device_uuid TEXT PRIMARY KEY, + alias TEXT, + key_fingerprint TEXT, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + last_generation INTEGER NOT NULL DEFAULT 0 +); + +-- ── remote landing tables (Overview/Cost core) ──────────────────────────────── +-- +-- Columns = ``device_uuid`` provenance + the re-keyed shard columns (local +-- ``project_id`` replaced by stable ``(provider, slug)``; ``session_mart.cwd`` +-- dropped at serialize time — never on the wire). A remote device's rows are +-- REPLACE-on-pull per changed shard; the merge overlay reads these UNION ALL the +-- local mart and SUMs at the stable grain. + +CREATE TABLE IF NOT EXISTS daily_mart_remote ( + device_uuid TEXT NOT NULL, + day TEXT NOT NULL, + provider TEXT NOT NULL, + slug TEXT NOT NULL, -- stable identity, NOT local project_id + model TEXT NOT NULL DEFAULT '', + speed TEXT NOT NULL DEFAULT 'standard', + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_create INTEGER NOT NULL DEFAULT 0, + message_count INTEGER NOT NULL DEFAULT 0, + session_count INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0.0, + PRIMARY KEY (device_uuid, provider, slug, day, model, speed) +); + +CREATE TABLE IF NOT EXISTS provider_day_mart_remote ( + device_uuid TEXT NOT NULL, + day TEXT NOT NULL, + provider TEXT NOT NULL, + cost_usd REAL NOT NULL DEFAULT 0.0, + message_count INTEGER NOT NULL DEFAULT 0, + session_count INTEGER NOT NULL DEFAULT 0, + project_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_uuid, day, provider) +); + +CREATE TABLE IF NOT EXISTS model_day_mart_remote ( + device_uuid TEXT NOT NULL, + day TEXT NOT NULL, + model TEXT NOT NULL, + speed TEXT NOT NULL DEFAULT 'standard', + cost_usd REAL NOT NULL DEFAULT 0.0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_create INTEGER NOT NULL DEFAULT 0, + message_count INTEGER NOT NULL DEFAULT 0, + session_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_uuid, day, model, speed) +); + +CREATE TABLE IF NOT EXISTS project_mart_remote ( + device_uuid TEXT NOT NULL, + provider TEXT NOT NULL, + slug TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + first_ts TEXT, + last_ts TEXT, + total_messages INTEGER NOT NULL DEFAULT 0, + total_sessions INTEGER NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cache_read INTEGER NOT NULL DEFAULT 0, + total_cache_create INTEGER NOT NULL DEFAULT 0, + total_cost_usd REAL NOT NULL DEFAULT 0.0, + PRIMARY KEY (device_uuid, provider, slug) +); + +CREATE TABLE IF NOT EXISTS session_mart_remote ( + device_uuid TEXT NOT NULL, + session_id TEXT NOT NULL, -- globally-unique UUID (no re-key) + provider TEXT NOT NULL, + slug TEXT NOT NULL, + primary_model TEXT, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + user_message_count INTEGER NOT NULL DEFAULT 0, + assistant_message_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_create INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0.0, + is_one_shot INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_uuid, session_id) +); + +-- Merge-read helper indexes (stable-grain lookups for the union overlay). +CREATE INDEX IF NOT EXISTS idx_daily_mart_remote_grain + ON daily_mart_remote(provider, slug, day); +CREATE INDEX IF NOT EXISTS idx_session_mart_remote_session + ON session_mart_remote(session_id); + +PRAGMA user_version = 29; + +COMMIT; diff --git a/stackunderflow/store/schema.py b/stackunderflow/store/schema.py index 129380e..1ebe048 100644 --- a/stackunderflow/store/schema.py +++ b/stackunderflow/store/schema.py @@ -26,7 +26,7 @@ _MIGRATIONS_DIR = Path(__file__).parent / "migrations" -CURRENT_VERSION = 28 +CURRENT_VERSION = 29 def apply(conn: sqlite3.Connection) -> None: @@ -123,6 +123,12 @@ def _run_python_migration( # ``user_version`` behind — bump the version without re-executing the body. # ``_column_exists`` doubles as a "does this table exist with this column?" probe. 28: ("sync_identity", "device_uuid"), + # v029 CREATEs the Phase 2 pull tables (``sync_cursors`` + + # ``sync_remote_devices`` + the five ``_remote`` landing tables). All + # ``CREATE TABLE IF NOT EXISTS`` so re-running is safe; the guard makes the + # partial-application path — tables present, ``user_version`` behind — bump + # the version without re-executing the body. + 29: ("sync_cursors", "remote_device_uuid"), } diff --git a/stackunderflow/sync/__init__.py b/stackunderflow/sync/__init__.py index db6e16d..3000290 100644 --- a/stackunderflow/sync/__init__.py +++ b/stackunderflow/sync/__init__.py @@ -22,10 +22,14 @@ * :mod:`stackunderflow.sync.serialize` — canonical, deterministic mart-shard serialization + SHA-256 content-hash, and the ``project_id`` → ``(provider, slug)`` re-keying. -* :mod:`stackunderflow.sync.runner` — ``init`` / ``push`` / ``status`` with a - two-phase manifest commit and a skip-if-unchanged outbox. +* :mod:`stackunderflow.sync.runner` — ``init`` / ``push`` / ``pull`` / ``status`` + with a two-phase manifest commit, a skip-if-unchanged outbox, and (Phase 2) + per-remote-device pull cursors landing into the ``_remote`` tables. +* :mod:`stackunderflow.sync.merge` — the Phase 2 cross-device union overlay + (``local UNION ALL _remote`` SUMmed at the stable grain), read-only and + opt-in behind ``?scope=all-devices``. """ from __future__ import annotations -__all__ = ["bucket", "cipher", "keys", "runner", "serialize"] +__all__ = ["bucket", "cipher", "keys", "merge", "runner", "serialize"] diff --git a/stackunderflow/sync/merge.py b/stackunderflow/sync/merge.py new file mode 100644 index 0000000..e505581 --- /dev/null +++ b/stackunderflow/sync/merge.py @@ -0,0 +1,290 @@ +"""The cross-device union overlay (Phase 2, §5 / §7). + +Because we sync *derived aggregates* and each session is aggregated exactly once +on its origin device, cross-device merge is an **additive union at the stable +grain**, not conflict resolution (§5.1). Each ``unioned_*`` here computes + + local mart (JOIN projects for slug where the mart keys on project_id) + UNION ALL _remote + GROUP BY (provider, slug, …) SUM(measures) + +so two devices' disjoint contributions SUM exactly — including ``session_count``, +which is safe *across devices* precisely because a session never spans machines +(the v007 additive-DISTINCT trap is about summing across grain *within* a mart, +not across devices at the same grain). + +``session_mart`` is the one non-additive case: the same ``session_id`` can appear +on two devices only if the user hand-copied raw logs between them (§5.3). We +dedup by the globally-unique ``session_id`` — deterministic tiebreak: **local +wins, then lowest device_uuid** — and count every dropped duplicate into +``merge_warnings`` for observability. Spec #16's deterministic content-hash IDs +make the same session reproduce the same id on both machines, so a duplicate is +caught by equality rather than a heuristic. + +Everything here is **read-only** and **opt-in**: routes call it only when sync is +enabled *and* the caller asked for ``?scope=all-devices``. With sync off (or the +default this-device scope) not one of these queries runs, so the mart ``<100ms`` +fast-path and ``test_pricing_invariants`` are untouched (this module never reads +``usage_events`` / ``price_book``). +""" + +from __future__ import annotations + +import sqlite3 + +# ── daily ─────────────────────────────────────────────────────────────────────── + +_UNIONED_DAILY = """ +SELECT day, provider, slug, model, speed, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cache_read) AS cache_read, + SUM(cache_create) AS cache_create, + SUM(message_count) AS message_count, + SUM(session_count) AS session_count, + SUM(cost_usd) AS cost_usd +FROM ( + SELECT d.day, d.provider, p.slug, d.model, d.speed, + d.input_tokens, d.output_tokens, d.cache_read, d.cache_create, + d.message_count, d.session_count, d.cost_usd + FROM daily_mart d JOIN projects p ON p.id = d.project_id + UNION ALL + SELECT day, provider, slug, model, speed, + input_tokens, output_tokens, cache_read, cache_create, + message_count, session_count, cost_usd + FROM daily_mart_remote +) +GROUP BY day, provider, slug, model, speed +ORDER BY day, provider, slug, model, speed +""" + +# ── provider × day ────────────────────────────────────────────────────────────── +# +# ``project_count`` is SUMmed at the stable grain like the spec's mechanical rule +# says; note it can *overcount* a project active on two devices the same day (a +# distinct-count that isn't additive across devices — the documented §5.1 family +# of limitations). The additive measures (cost / messages / sessions) are exact. +_UNIONED_PROVIDER_DAY = """ +SELECT day, provider, + SUM(cost_usd) AS cost_usd, + SUM(message_count) AS message_count, + SUM(session_count) AS session_count, + SUM(project_count) AS project_count +FROM ( + SELECT day, provider, cost_usd, message_count, session_count, project_count + FROM provider_day_mart + UNION ALL + SELECT day, provider, cost_usd, message_count, session_count, project_count + FROM provider_day_mart_remote +) +GROUP BY day, provider +ORDER BY day, provider +""" + +# ── model × day ───────────────────────────────────────────────────────────────── + +_UNIONED_MODEL_DAY = """ +SELECT day, model, speed, + SUM(cost_usd) AS cost_usd, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cache_read) AS cache_read, + SUM(cache_create) AS cache_create, + SUM(message_count) AS message_count, + SUM(session_count) AS session_count +FROM ( + SELECT day, model, speed, cost_usd, input_tokens, output_tokens, + cache_read, cache_create, message_count, session_count + FROM model_day_mart + UNION ALL + SELECT day, model, speed, cost_usd, input_tokens, output_tokens, + cache_read, cache_create, message_count, session_count + FROM model_day_mart_remote +) +GROUP BY day, model, speed +ORDER BY day, model, speed +""" + +# ── project ───────────────────────────────────────────────────────────────────── +# +# ``project_mart`` already carries ``(provider, slug, display_name)`` — no JOIN +# needed. ``first_ts`` / ``last_ts`` take the widest window across devices; +# ``display_name`` is the max (deterministic) of the contributing names. +_UNIONED_PROJECTS = """ +SELECT provider, slug, + MAX(display_name) AS display_name, + MIN(first_ts) AS first_ts, + MAX(last_ts) AS last_ts, + SUM(total_messages) AS total_messages, + SUM(total_sessions) AS total_sessions, + SUM(total_input_tokens) AS total_input_tokens, + SUM(total_output_tokens) AS total_output_tokens, + SUM(total_cache_read) AS total_cache_read, + SUM(total_cache_create) AS total_cache_create, + SUM(total_cost_usd) AS total_cost_usd +FROM ( + SELECT provider, slug, display_name, first_ts, last_ts, + total_messages, total_sessions, total_input_tokens, total_output_tokens, + total_cache_read, total_cache_create, total_cost_usd + FROM project_mart + UNION ALL + SELECT provider, slug, display_name, first_ts, last_ts, + total_messages, total_sessions, total_input_tokens, total_output_tokens, + total_cache_read, total_cache_create, total_cost_usd + FROM project_mart_remote +) +GROUP BY provider, slug +ORDER BY provider, slug +""" + +# ── session (dedup, not additive) ─────────────────────────────────────────────── +# +# Local rows carry device ``''`` (empty string) which sorts before any hex UUID, +# so the ``ORDER BY session_id, device_uuid`` makes **local win** the tiebreak, +# then the lowest remote device_uuid — a deterministic "earliest-device" rule +# with no wall-clock dependence. +_UNIONED_SESSIONS = """ +SELECT '' AS device_uuid, s.session_id, s.provider, p.slug, s.primary_model, + s.first_ts, s.last_ts, s.message_count, s.user_message_count, + s.assistant_message_count, s.input_tokens, s.output_tokens, + s.cache_read, s.cache_create, s.cost_usd, s.is_one_shot +FROM session_mart s JOIN projects p ON p.id = s.project_id +UNION ALL +SELECT device_uuid, session_id, provider, slug, primary_model, + first_ts, last_ts, message_count, user_message_count, + assistant_message_count, input_tokens, output_tokens, + cache_read, cache_create, cost_usd, is_one_shot +FROM session_mart_remote +ORDER BY session_id, device_uuid +""" + + +def unioned_daily(conn: sqlite3.Connection) -> list[dict]: + """Local + remote daily rows, SUMmed at ``(day, provider, slug, model, speed)``.""" + return [dict(r) for r in conn.execute(_UNIONED_DAILY).fetchall()] + + +def unioned_provider_day(conn: sqlite3.Connection) -> list[dict]: + """Local + remote provider×day rows, SUMmed at ``(day, provider)``.""" + return [dict(r) for r in conn.execute(_UNIONED_PROVIDER_DAY).fetchall()] + + +def unioned_model_day(conn: sqlite3.Connection) -> list[dict]: + """Local + remote model×day rows, SUMmed at ``(day, model, speed)``.""" + return [dict(r) for r in conn.execute(_UNIONED_MODEL_DAY).fetchall()] + + +def unioned_projects(conn: sqlite3.Connection) -> list[dict]: + """Local + remote project totals, SUMmed at the stable ``(provider, slug)``.""" + return [dict(r) for r in conn.execute(_UNIONED_PROJECTS).fetchall()] + + +def unioned_sessions(conn: sqlite3.Connection) -> tuple[list[dict], int]: + """Deduped session rows + a ``merge_warnings`` count of dropped duplicates. + + A ``session_id`` seen on more than one device is kept once (local-then-lowest- + device tiebreak) and every extra sighting increments the warning count — the + cross-device double-count guard of §5.3. + """ + rows = conn.execute(_UNIONED_SESSIONS).fetchall() + seen: set[str] = set() + out: list[dict] = [] + merge_warnings = 0 + for r in rows: + sid = r["session_id"] + if sid in seen: + merge_warnings += 1 + continue + seen.add(sid) + out.append(dict(r)) + return out, merge_warnings + + +def device_breakdown(conn: sqlite3.Connection) -> list[dict]: + """Per-contributing-device roll-up (this device + each pulled peer). + + Reads only ``project_mart`` (local) and ``project_mart_remote`` (peers), so it + is cheap and safe on any store. ``alias`` comes from ``sync_remote_devices``. + """ + out: list[dict] = [] + local = conn.execute( + "SELECT COUNT(*) AS projects, COALESCE(SUM(total_cost_usd), 0.0) AS cost_usd " + "FROM project_mart" + ).fetchone() + out.append({ + "device_uuid": "(local)", + "alias": None, + "is_local": True, + "projects": int(local["projects"]), + "cost_usd": float(local["cost_usd"]), + }) + for r in conn.execute( + "SELECT r.device_uuid AS device_uuid, d.alias AS alias, " + " COUNT(*) AS projects, COALESCE(SUM(r.total_cost_usd), 0.0) AS cost_usd " + "FROM project_mart_remote r " + "LEFT JOIN sync_remote_devices d ON d.remote_device_uuid = r.device_uuid " + "GROUP BY r.device_uuid, d.alias " + "ORDER BY r.device_uuid" + ).fetchall(): + out.append({ + "device_uuid": r["device_uuid"], + "alias": r["alias"], + "is_local": False, + "projects": int(r["projects"]), + "cost_usd": float(r["cost_usd"]), + }) + return out + + +def remote_row_count(conn: sqlite3.Connection) -> int: + """Total rows landed across every ``_remote`` table (0 ⇒ nothing pulled).""" + total = 0 + for family in ("daily_mart", "provider_day_mart", "model_day_mart", + "project_mart", "session_mart"): + row = conn.execute(f"SELECT COUNT(*) AS n FROM {family}_remote").fetchone() + total += int(row["n"]) + return total + + +def merged_overview(conn: sqlite3.Connection) -> dict: + """Assemble the compact cross-device overview payload (USD; the route converts). + + Totals come from the finest union we compute (daily) for cost / tokens / + messages; the unique session count and ``merge_warnings`` come from the + session dedup. ``by_day`` rolls the daily union up to a per-day trend. + """ + daily = unioned_daily(conn) + projects = unioned_projects(conn) + provider_day = unioned_provider_day(conn) + sessions, merge_warnings = unioned_sessions(conn) + devices = device_breakdown(conn) + + totals = { + "cost_usd": sum(r["cost_usd"] for r in daily), + "input_tokens": sum(r["input_tokens"] for r in daily), + "output_tokens": sum(r["output_tokens"] for r in daily), + "cache_read": sum(r["cache_read"] for r in daily), + "cache_create": sum(r["cache_create"] for r in daily), + "message_count": sum(r["message_count"] for r in daily), + "session_count": len(sessions), # deduped unique sessions across devices + } + + by_day: dict[str, dict] = {} + for r in daily: + bucket = by_day.setdefault( + r["day"], {"day": r["day"], "cost_usd": 0.0, + "input_tokens": 0, "output_tokens": 0, "message_count": 0} + ) + bucket["cost_usd"] += r["cost_usd"] + bucket["input_tokens"] += r["input_tokens"] + bucket["output_tokens"] += r["output_tokens"] + bucket["message_count"] += r["message_count"] + + return { + "totals": totals, + "by_day": [by_day[d] for d in sorted(by_day)], + "by_project": projects, + "by_provider_day": provider_day, + "devices": devices, + "merge_warnings": merge_warnings, + } diff --git a/stackunderflow/sync/runner.py b/stackunderflow/sync/runner.py index 347e95c..c54c19a 100644 --- a/stackunderflow/sync/runner.py +++ b/stackunderflow/sync/runner.py @@ -36,6 +36,7 @@ MANIFEST_SCHEMA = "stackunderflow.sync/1" Encryptor = Callable[[bytes], bytes] +Decryptor = Callable[[bytes], bytes] class SyncError(RuntimeError): @@ -292,6 +293,280 @@ def _encrypt(plaintext: bytes) -> bytes: ) +# ── pull (Phase 2) ───────────────────────────────────────────────────────────── +# +# ``pull`` is the mirror of ``push``: dependency-free and injectable (a +# *decryptor* callable + an ``ObjectStore``), so idempotency / cursor / merge +# landing behaviour is fully testable without ``pyrage`` or ``boto3``. +# ``run_pull`` is the thin deps-wiring wrapper the CLI uses. Pull is strictly +# READ-ONLY against the bucket — it only ``list``/``get`` other devices' prefixes +# and never writes any object (the "merge doesn't write to remote on read" +# invariant, §4.1), and it never touches ``usage_events`` / ``price_book`` / +# transcripts — decrypted remote rows land only in the ``_remote`` tables. + + +def _remote_device_uuids( + store, self_device_uuid: str, *, prefix: str = DEFAULT_PREFIX +) -> list[str]: + """LIST the sync root and return every *other* device's UUID (skip our own).""" + root = f"{prefix}/" + uuids: set[str] = set() + for key in store.list(root): + if not key.startswith(root): + continue + seg = key[len(root):].split("/", 1)[0] + if seg and seg != self_device_uuid: + uuids.add(seg) + return sorted(uuids) + + +def _last_generation(conn: sqlite3.Connection, remote_uuid: str) -> int: + """Highest manifest generation we have accepted for *remote_uuid* (0 if new).""" + row = conn.execute( + "SELECT last_generation FROM sync_remote_devices WHERE remote_device_uuid = ?", + (remote_uuid,), + ).fetchone() + return int(row["last_generation"]) if row is not None else 0 + + +def _upsert_remote_device( + conn: sqlite3.Connection, + remote_uuid: str, + *, + key_fingerprint: str | None, + generation: int, + now: str, +) -> None: + """Record/refresh a peer: first/last seen, fingerprint, monotonic generation.""" + conn.execute( + "INSERT INTO sync_remote_devices " + "(remote_device_uuid, alias, key_fingerprint, first_seen, last_seen, last_generation) " + "VALUES (?, NULL, ?, ?, ?, ?) " + "ON CONFLICT(remote_device_uuid) DO UPDATE SET " + " key_fingerprint = excluded.key_fingerprint, " + " last_seen = excluded.last_seen, " + " last_generation = MAX(sync_remote_devices.last_generation, excluded.last_generation)", + (remote_uuid, key_fingerprint, now, now, generation), + ) + + +def _cursor_hash(conn: sqlite3.Connection, remote_uuid: str, shard_key: str) -> str | None: + """The content-hash we last ingested for ``(remote device, shard)`` — or ``None``.""" + row = conn.execute( + "SELECT remote_content_hash FROM sync_cursors " + "WHERE remote_device_uuid = ? AND shard_key = ?", + (remote_uuid, shard_key), + ).fetchone() + return row["remote_content_hash"] if row is not None else None + + +def _advance_cursor( + conn: sqlite3.Connection, remote_uuid: str, shard_key: str, content_hash: str, now: str +) -> None: + """Record that ``(remote device, shard)`` is landed at *content_hash*.""" + conn.execute( + "INSERT INTO sync_cursors " + "(remote_device_uuid, shard_key, remote_content_hash, pulled_at) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(remote_device_uuid, shard_key) DO UPDATE SET " + " remote_content_hash = excluded.remote_content_hash, " + " pulled_at = excluded.pulled_at", + (remote_uuid, shard_key, content_hash, now), + ) + + +def _land_shard(conn: sqlite3.Connection, remote_uuid: str, shard) -> None: + """REPLACE *remote_uuid*'s rows for this ``(family, month)`` in ``_remote``. + + Table + column names come only from the fixed ``serialize`` family list + (the caller has already checked ``shard.family``/``shard.columns`` against + it), never from decrypted content, so the interpolation can't inject. The + delete is month-scoped so re-ingesting one month never wipes the device's + other months; a month-less mart (``project_mart``) replaces the device wholesale. + """ + table = serialize.remote_table(shard.family) + month_col = serialize.MONTH_COLUMN[shard.family] + if month_col is None: + conn.execute(f"DELETE FROM {table} WHERE device_uuid = ?", (remote_uuid,)) + else: + conn.execute( + f"DELETE FROM {table} WHERE device_uuid = ? AND substr({month_col}, 1, 7) = ?", + (remote_uuid, shard.month), + ) + columns = ("device_uuid", *shard.columns) + placeholders = ", ".join(["?"] * len(columns)) + collist = ", ".join(columns) + conn.executemany( + f"INSERT OR REPLACE INTO {table} ({collist}) VALUES ({placeholders})", + [(remote_uuid, *row) for row in shard.rows], + ) + + +@dataclass +class PullResult: + """Outcome of a :func:`pull`.""" + + devices_seen: int + shards_ingested: int + downloaded: int + skipped: int + warnings: list[str] = field(default_factory=list) + device_uuids: list[str] = field(default_factory=list) + + def as_dict(self) -> dict: + return { + "devices_seen": self.devices_seen, + "shards_ingested": self.shards_ingested, + "downloaded": self.downloaded, + "skipped": self.skipped, + "warnings": list(self.warnings), + "warning_count": len(self.warnings), + "device_uuids": list(self.device_uuids), + } + + +def pull( + conn: sqlite3.Connection, + store, + *, + self_device_uuid: str, + decryptor: Decryptor, + now: str | None = None, + prefix: str = DEFAULT_PREFIX, +) -> PullResult: + """Fetch, decrypt and land every *other* device's changed aggregate shards. + + Pure w.r.t. optional dependencies — *decryptor* and *store* are injected. + Idempotent: a shard whose manifest content-hash equals its ``sync_cursors`` + row is skipped without a download (unchanged remote ⇒ zero shard GETs; only + the tiny per-device manifest — the commit point — is always read). Per-device + and per-shard failures never raise: they are collected into ``warnings`` and + the pull continues, so one corrupt blob or unreachable peer can't abort the + whole read (§9 failure injection). A manifest whose generation is *lower* + than the last we accepted for that device is rejected as a replay (§3.4). + """ + now = now or utcnow_iso() + remote_uuids = _remote_device_uuids(store, self_device_uuid, prefix=prefix) + warnings: list[str] = [] + seen = ingested = downloaded = skipped = 0 + + for remote_uuid in remote_uuids: + try: + manifest_ct = store.get(manifest_key(remote_uuid, prefix=prefix)) + except Exception as exc: # ObjectNotFound / transport error — skip peer + warnings.append(f"{remote_uuid}: manifest unreadable ({exc})") + continue + try: + manifest = json.loads(decryptor(manifest_ct)) + except Exception as exc: # DecryptError / bad JSON — skip peer + warnings.append(f"{remote_uuid}: manifest decrypt/parse failed ({exc})") + continue + if not isinstance(manifest, dict) or manifest.get("schema") != MANIFEST_SCHEMA: + warnings.append(f"{remote_uuid}: unrecognised manifest schema") + continue + + gen = int(manifest.get("generation", 0)) + last_gen = _last_generation(conn, remote_uuid) + if gen < last_gen: + warnings.append( + f"{remote_uuid}: stale manifest (generation {gen} < accepted {last_gen}) — rejected" + ) + continue + + seen += 1 + _upsert_remote_device( + conn, remote_uuid, + key_fingerprint=manifest.get("key_fingerprint"), + generation=gen, now=now, + ) + + for shard_key, entry in sorted(manifest.get("shards", {}).items()): + expected = entry.get("content_hash") + if _cursor_hash(conn, remote_uuid, shard_key) == expected: + skipped += 1 + continue # unchanged remote shard ⇒ no download (idempotent) + try: + shard_ct = store.get(entry.get("object_key")) + except Exception as exc: # manifest references a missing/unreadable object + warnings.append(f"{remote_uuid}/{shard_key}: object unreadable ({exc})") + continue + downloaded += 1 + try: + shard = serialize.shard_from_bytes(decryptor(shard_ct)) + except Exception as exc: # DecryptError / truncated / bad bytes + warnings.append(f"{remote_uuid}/{shard_key}: decrypt/parse failed ({exc})") + continue + if shard.content_hash != expected: + warnings.append(f"{remote_uuid}/{shard_key}: content-hash mismatch — skipped") + continue + if shard.family not in serialize.MART_FAMILIES: + warnings.append(f"{remote_uuid}/{shard_key}: unknown family {shard.family!r} — skipped") + continue + if tuple(shard.columns) != serialize.SHARD_COLUMNS[shard.family]: + warnings.append(f"{remote_uuid}/{shard_key}: shard columns differ from local schema — skipped") + continue + _land_shard(conn, remote_uuid, shard) + _advance_cursor(conn, remote_uuid, shard_key, expected, now) + ingested += 1 + + return PullResult( + devices_seen=seen, + shards_ingested=ingested, + downloaded=downloaded, + skipped=skipped, + warnings=warnings, + device_uuids=remote_uuids, + ) + + +def run_pull( + conn: sqlite3.Connection, + *, + state_dir, + store=None, + env: dict[str, str] | None = None, + now: str | None = None, +) -> PullResult: + """Resolve the key + bucket, then :func:`pull`. Wires the optional deps. + + In the v1 shared-key model every device holds the *same* age identity, so the + local secret decrypts peers' manifests and shards. Raises the same + config/key failure modes as :func:`run_push`. + """ + from . import bucket, cipher, keys + + identity = load_identity(conn) + if identity is None: + raise SyncNotConfigured("sync is not configured — run `stackunderflow sync init` first") + + secret = keys.resolve_secret(state_dir, env=env) + if secret is None: + raise SyncKeyMissing( + "no sync key found — set STACKUNDERFLOW_SYNC_KEY, add it to the keychain, " + f"or place it at {keys.identity_path(state_dir)}" + ) + + recipient = keys.recipient_for(secret) + if keys.fingerprint(recipient) != identity["key_fingerprint"]: + raise SyncKeyMismatch( + "the resolved key does not match the fingerprint recorded at `sync init` " + f"({identity['key_fingerprint']}) — check STACKUNDERFLOW_SYNC_KEY / the key file" + ) + + def _decrypt(ciphertext: bytes) -> bytes: + return cipher.decrypt(ciphertext, secret) + + if store is None: + store = bucket.s3_store_from_url(identity["bucket_url"], identity["endpoint_url"]) + + return pull( + conn, store, + self_device_uuid=identity["device_uuid"], + decryptor=_decrypt, + now=now, + ) + + # ── status ──────────────────────────────────────────────────────────────────── diff --git a/stackunderflow/sync/serialize.py b/stackunderflow/sync/serialize.py index fe36b10..8cc7894 100644 --- a/stackunderflow/sync/serialize.py +++ b/stackunderflow/sync/serialize.py @@ -135,6 +135,22 @@ class _MartSpec: #: The mart families the MVP syncs, in a stable order. MART_FAMILIES: tuple[str, ...] = tuple(spec.family for spec in _SPECS) +#: Canonical shard columns per family — the single source of truth the v029 +#: ``_remote`` landing DDL and the pull upsert both key off, so a column +#: added to a shard here can't silently drift from where pull lands it. +SHARD_COLUMNS: dict[str, tuple[str, ...]] = {spec.family: spec.columns for spec in _SPECS} + +#: The column whose ``YYYY-MM`` prefix buckets a family into monthly shards +#: (``None`` = a single ``"all"`` shard). Pull uses it to scope a REPLACE to the +#: one ``(family, month)`` it re-ingested, so re-pulling one month never wipes +#: a remote device's other months. +MONTH_COLUMN: dict[str, str | None] = {spec.family: spec.month_column for spec in _SPECS} + + +def remote_table(family: str) -> str: + """Landing-table name for a mart *family* (``daily_mart`` → ``daily_mart_remote``).""" + return f"{family}_remote" + #: Serialization format version, embedded in each shard's canonical bytes. FORMAT_VERSION = 1 diff --git a/tests/stackunderflow/routes/test_sync_route.py b/tests/stackunderflow/routes/test_sync_route.py new file mode 100644 index 0000000..869642d --- /dev/null +++ b/tests/stackunderflow/routes/test_sync_route.py @@ -0,0 +1,164 @@ +"""``/api/sync/status`` + ``/api/sync/overview`` — the Phase 2 read surface. + +The overriding contract: the **default** ``this-device`` scope runs NO union and +returns a tiny stub even when remote rows are present, so the existing dashboard +path is byte-identical whether or not sync is enabled. Only an explicit +``?scope=all-devices`` (with sync on) computes the merged view. +""" + +from __future__ import annotations + +import pytest + +from stackunderflow.routes import sync as sync_route +from stackunderflow.store import db, schema + + +def _seed(store_db, *, with_remote=True): + """Local marts (project alpha) + optionally a peer 'dev-B' in the _remote tables.""" + conn = db.connect(store_db) + schema.apply(conn) + conn.execute( + "INSERT INTO projects (id, provider, slug, path, display_name, first_seen, last_modified) " + "VALUES (1, 'claude', 'alpha', '/a', 'Alpha', 0, 0)" + ) + conn.execute( + "INSERT INTO daily_mart (day, project_id, provider, model, speed, input_tokens, " + "output_tokens, cache_read, cache_create, message_count, session_count, cost_usd) " + "VALUES ('2026-07-01', 1, 'claude', 'opus', 'standard', 100, 50, 0, 0, 3, 1, 1.5)" + ) + conn.execute( + "INSERT INTO project_mart (project_id, provider, slug, display_name, first_ts, last_ts, " + "total_messages, total_sessions, total_input_tokens, total_output_tokens, " + "total_cache_read, total_cache_create, total_cost_usd) " + "VALUES (1, 'claude', 'alpha', 'Alpha', '2026-07-01', '2026-07-01', 3, 1, 100, 50, 0, 0, 1.5)" + ) + conn.execute( + "INSERT INTO session_mart (session_id, project_id, provider, primary_model, first_ts, " + "last_ts, message_count, user_message_count, assistant_message_count, input_tokens, " + "output_tokens, cache_read, cache_create, cost_usd, is_one_shot, cwd) " + "VALUES ('s-local', 1, 'claude', 'opus', '2026-07-01', '2026-07-01', 3, 1, 2, 100, 50, 0, 0, 1.5, 0, '/a')" + ) + if with_remote: + conn.execute( + "INSERT INTO daily_mart_remote (device_uuid, day, provider, slug, model, speed, " + "input_tokens, output_tokens, cache_read, cache_create, message_count, session_count, cost_usd) " + "VALUES ('dev-B', '2026-07-01', 'claude', 'alpha', 'opus', 'standard', 200, 80, 0, 0, 4, 1, 2.5)" + ) + conn.execute( + "INSERT INTO project_mart_remote (device_uuid, provider, slug, display_name, first_ts, " + "last_ts, total_messages, total_sessions, total_input_tokens, total_output_tokens, " + "total_cache_read, total_cache_create, total_cost_usd) " + "VALUES ('dev-B', 'claude', 'alpha', 'Alpha', '2026-07-01', '2026-07-01', 4, 1, 200, 80, 0, 0, 2.5)" + ) + conn.execute( + "INSERT INTO session_mart_remote (device_uuid, session_id, provider, slug, primary_model, " + "first_ts, last_ts, message_count, user_message_count, assistant_message_count, " + "input_tokens, output_tokens, cache_read, cache_create, cost_usd, is_one_shot) " + "VALUES ('dev-B', 's-remote', 'claude', 'alpha', 'opus', '2026-07-01', '2026-07-01', " + "4, 1, 3, 200, 80, 0, 0, 2.5, 0)" + ) + conn.execute( + "INSERT INTO sync_remote_devices (remote_device_uuid, alias, key_fingerprint, " + "first_seen, last_seen, last_generation) VALUES ('dev-B', 'work-mac', 'fp', 't', 't', 3)" + ) + conn.commit() + conn.close() + + +def _enable_sync(store_db): + conn = db.connect(store_db) + conn.execute( + "INSERT OR REPLACE INTO sync_identity (id, device_uuid, key_fingerprint, bucket_url, " + "endpoint_url, layout_version, created_at) " + "VALUES (1, 'dev-A', 'fp-A', 's3://b', NULL, 1, 't')" + ) + conn.commit() + conn.close() + + +def _prep(tmp_path, monkeypatch, *, with_remote=True, enabled=False): + store_db = tmp_path / "store.db" + _seed(store_db, with_remote=with_remote) + if enabled: + _enable_sync(store_db) + monkeypatch.setattr("stackunderflow.deps.store_path", store_db) + return store_db + + +# ── status ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_status_off_reports_disabled(tmp_path, monkeypatch): + _prep(tmp_path, monkeypatch, with_remote=False, enabled=False) + body = await sync_route.get_sync_status() + assert body["enabled"] is False + assert body["peers"] == [] + assert body["all_devices_available"] is False + + +@pytest.mark.asyncio +async def test_status_on_lists_peers_and_availability(tmp_path, monkeypatch): + _prep(tmp_path, monkeypatch, with_remote=True, enabled=True) + body = await sync_route.get_sync_status() + assert body["enabled"] is True + assert body["peer_count"] == 1 + assert body["peers"][0]["remote_device_uuid"] == "dev-B" + assert body["peers"][0]["alias"] == "work-mac" + assert body["remote_rows"] > 0 + assert body["all_devices_available"] is True + + +# ── overview: default this-device is inert ────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_overview_default_scope_never_merges(tmp_path, monkeypatch): + """Default scope returns the stub and merges nothing — even with peers pulled + and sync enabled. This is the byte-identical default path.""" + _prep(tmp_path, monkeypatch, with_remote=True, enabled=True) + body = await sync_route.get_sync_overview() # no scope arg = default + assert body["merged"] is False + assert body["scope"] == "this-device" + assert "totals" not in body # no union computed + + +@pytest.mark.asyncio +async def test_overview_all_devices_disabled_returns_stub(tmp_path, monkeypatch): + """?scope=all-devices with sync OFF still returns the stub (nothing to merge).""" + _prep(tmp_path, monkeypatch, with_remote=True, enabled=False) + body = await sync_route.get_sync_overview(scope="all-devices") + assert body["merged"] is False + assert body["sync_enabled"] is False + + +# ── overview: opt-in merged view ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_overview_all_devices_merges_local_and_remote(tmp_path, monkeypatch): + _prep(tmp_path, monkeypatch, with_remote=True, enabled=True) + body = await sync_route.get_sync_overview(scope="all-devices") + assert body["merged"] is True + assert body["scope"] == "all-devices" + # local(1.5) + remote(2.5) = 4.0 cost; disjoint sessions s-local + s-remote = 2. + assert body["totals"]["cost_usd"] == pytest.approx(4.0) + assert body["totals"]["input_tokens"] == 300 + assert body["totals"]["session_count"] == 2 + alpha = next(p for p in body["by_project"] if p["slug"] == "alpha") + assert alpha["total_cost_usd"] == pytest.approx(4.0) + device_ids = {d["device_uuid"] for d in body["devices"]} + assert device_ids == {"(local)", "dev-B"} + assert body["merge_warnings"] == 0 + assert body["currency"]["code"] == "USD" + + +@pytest.mark.asyncio +async def test_overview_all_devices_no_peers_is_local_only(tmp_path, monkeypatch): + """Enabled but nothing pulled ⇒ merged view equals the local totals.""" + _prep(tmp_path, monkeypatch, with_remote=False, enabled=True) + body = await sync_route.get_sync_overview(scope="all-devices") + assert body["merged"] is True + assert body["totals"]["cost_usd"] == pytest.approx(1.5) + assert body["totals"]["session_count"] == 1 diff --git a/tests/stackunderflow/store/test_migration_v028.py b/tests/stackunderflow/store/test_migration_v028.py index 3cf6499..97105da 100644 --- a/tests/stackunderflow/store/test_migration_v028.py +++ b/tests/stackunderflow/store/test_migration_v028.py @@ -80,9 +80,12 @@ def test_v028_is_purely_additive_on_a_genuine_v27_store(tmp_path: Path) -> None: before_tables = _tables(conn) before_cols = {t: _columns(conn, t) for t in before_tables} - schema.apply(conn) # v27 → head + # Apply ONLY v028 (not the whole chain to head, so later additive + # migrations don't pollute the v28-specific diff this test isolates). + v028_path = next(p for v, p in schema._discover() if v == 28) + conn.executescript(v028_path.read_text()) - assert conn.execute("PRAGMA user_version").fetchone()[0] == schema.CURRENT_VERSION + assert conn.execute("PRAGMA user_version").fetchone()[0] == 28 after_tables = _tables(conn) # Exactly the two new tables were added. assert after_tables - before_tables == {"sync_identity", "sync_outbox"} diff --git a/tests/stackunderflow/store/test_migration_v029.py b/tests/stackunderflow/store/test_migration_v029.py new file mode 100644 index 0000000..1697d4f --- /dev/null +++ b/tests/stackunderflow/store/test_migration_v029.py @@ -0,0 +1,124 @@ +"""v029 — Phase 2 pull tables: ``sync_cursors`` + ``sync_remote_devices`` + the +five ``_remote`` landing tables. + +Asserts the migration is additive (no existing table touched), lands +``user_version`` on the current head, recovers from a partial application via the +``_ADD_COLUMN_GUARDS`` entry, and — crucially — that each landing table's columns +are exactly ``device_uuid`` + the serialized shard columns, so the DDL can never +drift from ``sync/serialize.py``. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from stackunderflow.store import db, schema +from stackunderflow.sync import serialize + +_NEW_TABLES = { + "sync_cursors", + "sync_remote_devices", + "daily_mart_remote", + "provider_day_mart_remote", + "model_day_mart_remote", + "project_mart_remote", + "session_mart_remote", +} + + +def _tables(conn) -> set[str]: + return { + r["name"] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')" + ).fetchall() + } + + +def _columns(conn, table) -> list[str]: + return [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + + +def test_v029_creates_all_pull_tables(tmp_path: Path) -> None: + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + assert _NEW_TABLES.issubset(_tables(conn)) + finally: + conn.close() + + +def test_v029_user_version_bumped(tmp_path: Path) -> None: + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + assert conn.execute("PRAGMA user_version").fetchone()[0] == schema.CURRENT_VERSION + assert schema.CURRENT_VERSION >= 29 + finally: + conn.close() + + +def test_v029_landing_columns_match_shard_columns(tmp_path: Path) -> None: + """Each ``_remote`` table = ``device_uuid`` + the family's shard columns. + + This is the contract the pull upsert relies on (it INSERTs ``('device_uuid',) + + shard.columns``); if the DDL and ``serialize._SPECS`` ever diverge this fails. + """ + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + for family, shard_cols in serialize.SHARD_COLUMNS.items(): + table = serialize.remote_table(family) + assert _columns(conn, table) == ["device_uuid", *shard_cols], ( + f"{table} columns drifted from the {family} shard columns" + ) + finally: + conn.close() + + +def test_v029_is_additive_leaves_existing_tables_unchanged(tmp_path: Path) -> None: + """The local marts + the v028 sync tables are untouched (no new columns).""" + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + # Local daily_mart keeps its project_id key — the re-key lives only in the + # remote twin, never in the local mart. + assert "project_id" in _columns(conn, "daily_mart") + assert "device_uuid" not in _columns(conn, "daily_mart") + # v028 tables still present and unchanged. + assert {"sync_identity", "sync_outbox"}.issubset(_tables(conn)) + assert "device_uuid" in _columns(conn, "sync_identity") + finally: + conn.close() + + +def test_v029_partial_application_recovers(tmp_path: Path) -> None: + """Tables hand-created, ``user_version`` behind ⇒ apply bumps without erroring.""" + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + # Rewind so v029 looks pending even though its tables exist. + conn.execute("PRAGMA user_version = 28") + schema.apply(conn) # guard short-circuits the body, still bumps the version + assert conn.execute("PRAGMA user_version").fetchone()[0] == schema.CURRENT_VERSION + assert _NEW_TABLES.issubset(_tables(conn)) + finally: + conn.close() + + +def test_v029_landing_tables_reject_missing_device_uuid(tmp_path: Path) -> None: + """``device_uuid`` is NOT NULL — provenance is mandatory on every landed row.""" + conn = db.connect(tmp_path / "store.db") + try: + schema.apply(conn) + try: + conn.execute( + "INSERT INTO daily_mart_remote (day, provider, slug) " + "VALUES ('2026-07-01', 'claude', 'alpha')" + ) + raise AssertionError("expected NOT NULL violation on device_uuid") + except sqlite3.IntegrityError: + pass + finally: + conn.close() diff --git a/tests/stackunderflow/sync/test_cli_sync.py b/tests/stackunderflow/sync/test_cli_sync.py index 8eb4845..a0748d9 100644 --- a/tests/stackunderflow/sync/test_cli_sync.py +++ b/tests/stackunderflow/sync/test_cli_sync.py @@ -151,3 +151,80 @@ def test_push_end_to_end_with_fake_bucket(tmp_path, monkeypatch, seed_marts): # status now shows no pending. r3 = CliRunner().invoke(cli, ["sync", "status"]) assert "pending upload: 0" in r3.output + + +# ── sync pull (Phase 2) ───────────────────────────────────────────────────────── + + +def test_pull_not_configured_exits_nonzero(tmp_path, monkeypatch): + _prep(tmp_path, monkeypatch) + monkeypatch.setattr(cli_mod, "_sync_missing_deps", lambda **_: []) # pretend deps present + r = CliRunner().invoke(cli, ["sync", "pull"]) + assert r.exit_code == 1 + assert "not configured" in r.output + + +def test_pull_no_peers_in_bucket(tmp_path, monkeypatch): + pytest.importorskip("pyrage") + _prep(tmp_path, monkeypatch) + assert CliRunner().invoke(cli, ["sync", "init", "--bucket", "s3://b"]).exit_code == 0 + + from stackunderflow.sync import bucket as bkt + + fake = bkt.InMemoryObjectStore() + monkeypatch.setattr("stackunderflow.sync.bucket.s3_store_from_url", lambda *a, **k: fake) + monkeypatch.setattr("stackunderflow.sync.keys._read_keychain", lambda service=None: None) + monkeypatch.setattr(cli_mod, "_sync_missing_deps", lambda **_: []) + + r = CliRunner().invoke(cli, ["sync", "pull"]) + assert r.exit_code == 0, r.output + assert "No other devices" in r.output + + +def test_pull_end_to_end_with_peer(tmp_path, monkeypatch, seed_marts): + pytest.importorskip("pyrage") + from stackunderflow.sync import bucket as bkt + from stackunderflow.sync import cipher, keys, runner + + store, state = _prep(tmp_path, monkeypatch) + assert CliRunner().invoke(cli, ["sync", "init", "--bucket", "s3://b"]).exit_code == 0 + + # A peer device pushes into the SAME bucket, encrypted to the shared key + # (v1 shared-key model — every device holds the one identity). + secret = (state / "sync-identity").read_text().strip() + recipient = keys.recipient_for(secret) + fake = bkt.InMemoryObjectStore() + peer = db.connect(tmp_path / "peer.db") + schema.apply(peer) + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + runner.push( + peer, fake, device_uuid="dev-peer", key_fingerprint=keys.fingerprint(recipient), + encryptor=lambda pt: cipher.encrypt(pt, recipient), + ) + peer.close() + + monkeypatch.setattr("stackunderflow.sync.bucket.s3_store_from_url", lambda *a, **k: fake) + monkeypatch.setattr("stackunderflow.sync.keys._read_keychain", lambda service=None: None) + monkeypatch.setattr(cli_mod, "_sync_missing_deps", lambda **_: []) + + r = CliRunner().invoke(cli, ["sync", "pull"]) + assert r.exit_code == 0, r.output + assert "Pulled" in r.output and "peer" in r.output + + # Idempotent second pull — nothing new. + r2 = CliRunner().invoke(cli, ["sync", "pull"]) + assert r2.exit_code == 0, r2.output + assert "Up to date" in r2.output + + r3 = CliRunner().invoke(cli, ["sync", "pull", "--json"]) + payload = json.loads(r3.output) + assert payload["devices_seen"] == 1 + assert payload["shards_ingested"] == 0 # already ingested on the first pull + + # The merged view is now populated in the local store. + conn = db.connect(store) + landed = conn.execute( + "SELECT COUNT(*) FROM daily_mart_remote WHERE device_uuid='dev-peer'" + ).fetchone()[0] + conn.close() + assert landed > 0 diff --git a/tests/stackunderflow/sync/test_merge.py b/tests/stackunderflow/sync/test_merge.py new file mode 100644 index 0000000..e7c433d --- /dev/null +++ b/tests/stackunderflow/sync/test_merge.py @@ -0,0 +1,189 @@ +"""The cross-device union overlay (Phase 2, §5). All dependency-free — remote +rows are landed by pushing a peer to a fake store with an identity encryptor and +pulling it back with an identity decryptor (the realistic landing path), so these +tests also cover the pull→merge seam. +""" + +from __future__ import annotations + +from stackunderflow.sync import bucket, merge, runner + + +def _land_peer(local, peer, uuid, store=None): + """Push *peer*'s shards and pull them into *local*'s ``_remote`` tables.""" + store = store or bucket.InMemoryObjectStore() + runner.push(peer, store, device_uuid=uuid, key_fingerprint="fp", encryptor=lambda pt: pt) + runner.pull(local, store, self_device_uuid="dev-local", decryptor=lambda ct: ct) + return store + + +def _daily(conn, day, slug): + return next(r for r in merge.unioned_daily(conn) if r["day"] == day and r["slug"] == slug) + + +# ── additive union at the stable grain (§5.1) ─────────────────────────────────── + + +def test_disjoint_sessions_sum_at_stable_grain(make_store, seed_marts): + """Two devices' disjoint contributions to the same (day, slug) SUM exactly, + including session_count (safe across devices — sessions never span machines).""" + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local", scale=1) + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer", scale=3) + _land_peer(local, peer, "dev-peer") + + row = _daily(local, "2026-07-01", "alpha") + assert row["input_tokens"] == 100 + 300 # local(1x) + peer(3x) + assert row["cost_usd"] == 1.5 + 4.5 + assert row["session_count"] == 1 + 1 # disjoint sessions add exactly + + projects = {p["slug"]: p for p in merge.unioned_projects(local)} + assert projects["alpha"]["total_input_tokens"] == 300 + 900 + assert projects["alpha"]["total_cost_usd"] == 4.0 + 12.0 + + +def test_local_only_when_no_remote_equals_local_marts(make_store, seed_marts): + """With nothing pulled, the union is byte-equal to the local mart alone — + the merge layer adds nothing until a peer lands.""" + local = make_store() + seed_marts(local, session_id="s-local") + + row = _daily(local, "2026-07-01", "alpha") + local_row = local.execute( + "SELECT SUM(input_tokens) i, SUM(cost_usd) c FROM daily_mart d " + "JOIN projects p ON p.id = d.project_id WHERE d.day='2026-07-01' AND p.slug='alpha'" + ).fetchone() + assert row["input_tokens"] == local_row["i"] + assert row["cost_usd"] == local_row["c"] + assert merge.remote_row_count(local) == 0 + + +# ── re-keying merges (§4.5 / §5.2) ────────────────────────────────────────────── + + +def test_rekey_merges_same_provider_slug_across_devices(make_store, seed_marts): + """Different local project_ids but the same (provider, slug) merge into ONE row.""" + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local") + seed_marts(peer, alpha_id=41, beta_id=42, session_id="s-peer") # different local ids + _land_peer(local, peer, "dev-peer") + + alpha_rows = [p for p in merge.unioned_projects(local) if p["slug"] == "alpha"] + assert len(alpha_rows) == 1 # merged, not two rows + # project_mart seeds total_sessions=2 per device ⇒ 2 + 2 summed across devices. + assert alpha_rows[0]["total_sessions"] == 2 + 2 + + +def test_different_slug_stays_two_projects(make_store, seed_marts): + """Same logical project at a different path → different slug → two rows (§5.2).""" + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + peer.execute("UPDATE project_mart SET slug='alpha2' WHERE slug='alpha'") + peer.execute("UPDATE projects SET slug='alpha2' WHERE slug='alpha'") + _land_peer(local, peer, "dev-peer") + + slugs = {p["slug"] for p in merge.unioned_projects(local)} + assert {"alpha", "alpha2"}.issubset(slugs) + + +# ── session dedup + merge_warnings (§5.3) ─────────────────────────────────────── + + +def test_same_session_on_two_devices_dedups_and_warns(make_store, seed_marts): + """The same session_id seen on two devices is kept once and flagged.""" + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="dup-session") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="dup-session") # SAME id + _land_peer(local, peer, "dev-peer") + + sessions, warnings = merge.unioned_sessions(local) + ids = [s["session_id"] for s in sessions] + assert ids.count("dup-session") == 1 # deduped + assert warnings == 1 # one duplicate flagged + # Local wins the tiebreak (empty device_uuid sorts first). + kept = next(s for s in sessions if s["session_id"] == "dup-session") + assert kept["device_uuid"] == "" + + +def test_disjoint_sessions_not_flagged(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + _land_peer(local, peer, "dev-peer") + + sessions, warnings = merge.unioned_sessions(local) + assert warnings == 0 + assert {s["session_id"] for s in sessions} == {"s-local", "s-peer"} + + +# ── provider_day / model_day unions ───────────────────────────────────────────── + + +def test_provider_day_and_model_day_union_sums(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local", scale=1) + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer", scale=2) + _land_peer(local, peer, "dev-peer") + + pd = {(r["day"], r["provider"]): r for r in merge.unioned_provider_day(local)} + assert pd[("2026-07-01", "claude")]["cost_usd"] == 1.5 + 3.0 + + md = {(r["day"], r["model"]): r for r in merge.unioned_model_day(local)} + assert md[("2026-07-01", "opus")]["input_tokens"] == 100 + 200 + + +# ── merged_overview + device breakdown ────────────────────────────────────────── + + +def test_merged_overview_shape_and_totals(make_store, seed_marts): + local = make_store() + b = make_store() + c = make_store() + seed_marts(local, alpha_id=1, beta_id=2, session_id="s-local") + seed_marts(b, alpha_id=7, beta_id=8, session_id="s-b") + seed_marts(c, alpha_id=9, beta_id=10, session_id="s-local") # duplicate of local's session + store = bucket.InMemoryObjectStore() + _land_peer(local, b, "dev-b", store=store) + _land_peer(local, c, "dev-c", store=store) + + ov = merge.merged_overview(local) + assert set(ov) == {"totals", "by_day", "by_project", "by_provider_day", "devices", "merge_warnings"} + # Each device seeds one session; dev-c re-uses local's id ⇒ unique {s-local, s-b} + # = 2 sessions with 1 duplicate flagged. + assert ov["totals"]["session_count"] == 2 + assert ov["merge_warnings"] == 1 + device_ids = {d["device_uuid"] for d in ov["devices"]} + assert device_ids == {"(local)", "dev-b", "dev-c"} + assert next(d for d in ov["devices"] if d["is_local"])["projects"] == 2 + + +def test_device_breakdown_carries_alias(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(local, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + _land_peer(local, peer, "dev-peer") + local.execute("UPDATE sync_remote_devices SET alias='work-mac' WHERE remote_device_uuid='dev-peer'") + + dev = next(d for d in merge.device_breakdown(local) if d["device_uuid"] == "dev-peer") + assert dev["alias"] == "work-mac" + assert dev["is_local"] is False + + +# ── invariant: merge never reads the fact table or the rate card ──────────────── + + +def test_merge_sql_never_touches_usage_events_or_price_book(): + for sql in ( + merge._UNIONED_DAILY, merge._UNIONED_PROVIDER_DAY, merge._UNIONED_MODEL_DAY, + merge._UNIONED_PROJECTS, merge._UNIONED_SESSIONS, + ): + assert "usage_events" not in sql + assert "price_book" not in sql diff --git a/tests/stackunderflow/sync/test_pull.py b/tests/stackunderflow/sync/test_pull.py new file mode 100644 index 0000000..f65263b --- /dev/null +++ b/tests/stackunderflow/sync/test_pull.py @@ -0,0 +1,312 @@ +"""``sync pull`` (Phase 2) — LIST/skip-own, generation guard, idempotency, +landing, and failure injection. + +The core ``runner.pull`` is exercised WITHOUT any optional dependency by +injecting an identity ``decryptor`` (``lambda ct: ct``) + the in-memory fake +store (so with an identity ``encryptor`` on the push side, the "ciphertext" is +plain shard/manifest JSON). The ``run_pull`` crypto integration is gated on +``pyrage``. +""" + +from __future__ import annotations + +import json + +import pytest + +from stackunderflow.sync import bucket, runner, serialize + +# Peer B pushes 8 shards: daily 06/07, provider_day 06/07, model_day 06/07, +# project all, session 07 (same shape the push tests assert). +_PEER_SHARDS = 8 + + +def _push_peer(store, conn, uuid, **kw): + """Push *conn*'s shards to *store* under device *uuid* (identity encryptor).""" + return runner.push( + conn, store, device_uuid=uuid, key_fingerprint="fp", + encryptor=lambda pt: pt, **kw, + ) + + +def _pull(conn, store, uuid="dev-local", **kw): + return runner.pull(conn, store, self_device_uuid=uuid, decryptor=lambda ct: ct, **kw) + + +# ── LIST / skip-own ───────────────────────────────────────────────────────────── + + +def test_pull_skips_our_own_prefix(make_store, seed_marts): + """A device never ingests its own pushed shards — only *other* prefixes.""" + local = make_store() + peer = make_store() + seed_marts(local, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + + store = bucket.InMemoryObjectStore() + _push_peer(store, local, "dev-local") # our own push + _push_peer(store, peer, "dev-peer") + + result = _pull(local, store, uuid="dev-local") + assert result.device_uuids == ["dev-peer"] # our own uuid filtered out + assert result.devices_seen == 1 + # Nothing landed carries our own device_uuid. + owned = local.execute( + "SELECT COUNT(*) FROM daily_mart_remote WHERE device_uuid = 'dev-local'" + ).fetchone()[0] + assert owned == 0 + + +def test_pull_lands_remote_shards_with_device_provenance(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(local, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + result = _pull(local, store) + + assert result.devices_seen == 1 + assert result.shards_ingested == _PEER_SHARDS + # Remote rows are tagged with the peer's uuid and carry slug (re-keyed), no project_id. + rows = local.execute( + "SELECT DISTINCT device_uuid, slug FROM daily_mart_remote ORDER BY slug" + ).fetchall() + assert {(r["device_uuid"], r["slug"]) for r in rows} == {("dev-peer", "alpha"), ("dev-peer", "beta")} + cols = {r["name"] for r in local.execute("PRAGMA table_info(daily_mart_remote)").fetchall()} + assert "project_id" not in cols and "device_uuid" in cols + + +# ── idempotency ───────────────────────────────────────────────────────────────── + + +def test_pull_idempotent_zero_downloads_when_unchanged(make_store, seed_marts): + """Re-pull with an unchanged remote ⇒ zero shard downloads (only the tiny + per-device manifest — the commit point — is re-read).""" + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + + first = _pull(local, store) + assert first.downloaded == _PEER_SHARDS + gets_after_first = store.get_calls + + second = _pull(local, store) + assert second.downloaded == 0 # ZERO shard downloads + assert second.shards_ingested == 0 + assert second.skipped == _PEER_SHARDS + # Only the one manifest was re-read (the commit point), no shard GETs. + assert store.get_calls - gets_after_first == 1 + + +def test_pull_redownloads_only_changed_shard(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + _pull(local, store) + + # Peer mutates one July daily row and re-pushes (new generation). + peer.execute("UPDATE daily_mart SET cost_usd = cost_usd + 5 WHERE day = '2026-07-01'") + _push_peer(store, peer, "dev-peer") + + result = _pull(local, store) + assert result.downloaded == 1 # only daily_mart.2026-07 changed + assert result.shards_ingested == 1 + assert result.skipped == _PEER_SHARDS - 1 + # The cursor advanced to the new hash for exactly that shard. + cur = local.execute( + "SELECT remote_content_hash FROM sync_cursors " + "WHERE remote_device_uuid='dev-peer' AND shard_key='daily_mart.2026-07'" + ).fetchone()[0] + live = {s.shard_key: s.content_hash for s in serialize.build_shards(peer)} + assert cur == live["daily_mart.2026-07"] + + +# ── generation-monotonicity (replay guard, §3.4) ──────────────────────────────── + + +def test_pull_rejects_stale_generation(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + _pull(local, store) # accepts generation 1 → last_generation(dev-peer)=1 + + assert local.execute( + "SELECT last_generation FROM sync_remote_devices WHERE remote_device_uuid='dev-peer'" + ).fetchone()[0] == 1 + + # A malicious bucket replays an OLD manifest (generation 0). + replay = { + "schema": runner.MANIFEST_SCHEMA, "device_uuid": "dev-peer", + "key_fingerprint": "fp", "generation": 0, "created_at": "t", + "layout_version": 1, "shards": {}, + } + store.put(runner.manifest_key("dev-peer"), json.dumps(replay).encode("utf-8")) + + result = _pull(local, store) + assert result.devices_seen == 0 # rejected, not counted + assert result.downloaded == 0 + assert any("stale" in w for w in result.warnings) + # last_generation is not walked backwards. + assert local.execute( + "SELECT last_generation FROM sync_remote_devices WHERE remote_device_uuid='dev-peer'" + ).fetchone()[0] == 1 + + +# ── failure injection (§9) ────────────────────────────────────────────────────── + + +def test_pull_missing_object_warns_and_continues(make_store, seed_marts): + """A manifest that references a missing object ⇒ skip + warn, other shards land.""" + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + # Delete one shard object the manifest still references. + store.delete("stackunderflow/v1/dev-peer/shards/daily_mart.2026-07.age") + + result = _pull(local, store) + assert result.shards_ingested == _PEER_SHARDS - 1 + assert any("daily_mart.2026-07" in w and "unreadable" in w for w in result.warnings) + # The missing shard's cursor is NOT advanced (a later healthy pull retries it). + assert local.execute( + "SELECT COUNT(*) FROM sync_cursors " + "WHERE remote_device_uuid='dev-peer' AND shard_key='daily_mart.2026-07'" + ).fetchone()[0] == 0 + + +def test_pull_content_hash_mismatch_skips(make_store, seed_marts): + """A blob valid-for-key but not matching the manifest hash ⇒ skip + warn.""" + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + + # Swap a shard object for a valid-but-different one (drops a row → new hash). + key = "stackunderflow/v1/dev-peer/shards/daily_mart.2026-07.age" + original = serialize.shard_from_bytes(store.get(key)) + swapped = serialize.Shard(original.family, original.month, original.columns, original.rows[:1]) + store._data[key] = swapped.to_bytes() # setup: bypass the put counter + + result = _pull(local, store) + assert any("mismatch" in w for w in result.warnings) + # The tampered shard did not land and its cursor was not advanced. + assert local.execute( + "SELECT COUNT(*) FROM sync_cursors " + "WHERE remote_device_uuid='dev-peer' AND shard_key='daily_mart.2026-07'" + ).fetchone()[0] == 0 + + +def test_pull_unreadable_manifest_warns_and_skips_peer(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + # Corrupt the manifest so json.loads fails on the identity-decrypted bytes. + store._data[runner.manifest_key("dev-peer")] = b"\x00 not json \xff" + + result = _pull(local, store) + assert result.devices_seen == 0 + assert result.shards_ingested == 0 + assert any("dev-peer" in w for w in result.warnings) + + +# ── invariants: pull is read-only on the bucket + local marts ─────────────────── + + +def test_pull_never_writes_to_the_bucket(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + puts_before = store.put_calls + deletes_before = store.delete_calls + + _pull(local, store) + assert store.put_calls == puts_before # never PUT — no writes to any prefix + assert store.delete_calls == deletes_before # never delete peers' objects + + +def test_pull_does_not_touch_local_marts_or_usage_events(make_store, seed_marts): + local = make_store() + peer = make_store() + seed_marts(local, session_id="s-local") + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + store = bucket.InMemoryObjectStore() + _push_peer(store, peer, "dev-peer") + + before = local.execute("SELECT count(*), total(cost_usd) FROM daily_mart").fetchone() + _pull(local, store) + after = local.execute("SELECT count(*), total(cost_usd) FROM daily_mart").fetchone() + assert tuple(before) == tuple(after) # local mart untouched + # The sync path never writes the fact table or the rate card. + assert local.execute("SELECT COUNT(*) FROM usage_events").fetchone()[0] == 0 + assert local.execute("SELECT COUNT(*) FROM price_book").fetchone()[0] == 0 + + +# ── run_pull (deps wiring) ────────────────────────────────────────────────────── + + +def test_run_pull_not_configured(make_store, tmp_path): + local = make_store() + with pytest.raises(runner.SyncNotConfigured): + runner.run_pull(local, state_dir=tmp_path, store=bucket.InMemoryObjectStore(), env={}) + + +def test_run_pull_with_real_crypto(make_store, seed_marts, tmp_path, monkeypatch): + pytest.importorskip("pyrage") + from stackunderflow.sync import cipher, keys + + local = make_store() + peer = make_store() + seed_marts(peer, alpha_id=7, beta_id=8, session_id="s-peer") + + # v1 shared-key model: both devices hold the SAME identity. + identity = keys.generate_identity() + store = bucket.InMemoryObjectStore() + runner.push( + peer, store, device_uuid="dev-peer", key_fingerprint=identity.fingerprint, + encryptor=lambda pt: cipher.encrypt(pt, identity.recipient), + ) + + keys.store_secret_file(identity.secret, tmp_path) + runner.write_identity( + local, device_uuid="dev-A", key_fingerprint=identity.fingerprint, + bucket_url="s3://b", endpoint_url=None, created_at="t", + ) + monkeypatch.setattr("stackunderflow.sync.keys._read_keychain", lambda service=None: None) + + result = runner.run_pull(local, state_dir=tmp_path, store=store, env={}) + assert result.shards_ingested == _PEER_SHARDS + assert result.warnings == [] + assert local.execute( + "SELECT COUNT(*) FROM daily_mart_remote WHERE device_uuid='dev-peer'" + ).fetchone()[0] > 0 + + +def test_run_pull_key_mismatch(make_store, seed_marts, tmp_path, monkeypatch): + pytest.importorskip("pyrage") + from stackunderflow.sync import keys + + local = make_store() + right = keys.generate_identity() + wrong = keys.generate_identity() + keys.store_secret_file(wrong.secret, tmp_path) # the file holds the WRONG key + runner.write_identity( + local, device_uuid="dev-A", key_fingerprint=right.fingerprint, + bucket_url="s3://b", endpoint_url=None, created_at="t", + ) + monkeypatch.setattr("stackunderflow.sync.keys._read_keychain", lambda service=None: None) + with pytest.raises(runner.SyncKeyMismatch): + runner.run_pull(local, state_dir=tmp_path, store=bucket.InMemoryObjectStore(), env={}) From 3fa894eb3bddc28707628a9300388ebc413ad5dc Mon Sep 17 00:00:00 2001 From: Yad Konrad Date: Fri, 3 Jul 2026 11:39:55 -0400 Subject: [PATCH 2/2] feat(hooks): error-signature nudge + 'What almost bit me' panel (#97 Phase 2) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + docs/specs/active-surfacing.md | 48 +- .../components/dashboard/CodingHealthTab.tsx | 169 ++++++- stackunderflow-ui/src/services/patterns.ts | 23 +- stackunderflow-ui/src/types/api.ts | 23 + stackunderflow/hooks/_install.py | 13 +- stackunderflow/hooks/handlers.py | 16 +- stackunderflow/hooks/proactive.py | 252 +++++++++- stackunderflow/hooks/templates.py | 56 ++- stackunderflow/routes/patterns.py | 75 +++ ...sTab-DJ-0Trzo.js => AgentsTab-BajlmbLz.js} | 2 +- ...b-DAjJ72Pj.js => BookmarksTab-Cor0cmyo.js} | 2 +- ...Tab-n3gfgUaU.js => BudgetsTab-BjHaaUsj.js} | 2 +- .../react/assets/CodingHealthTab-B02yOcgC.js | 7 + .../react/assets/CodingHealthTab-Cz4e1hEF.js | 7 - ...ab-D4oiPDTW.js => CommandsTab-DlkmMFl4.js} | 2 +- ...Tab-CVqkwtRX.js => CompareTab-D60fXyc4.js} | 2 +- ...TyR19Y.js => ContextReplayTab-ixP9t9_Y.js} | 2 +- ...ostTab-BAdmE1EC.js => CostTab-DCJQfNNf.js} | 2 +- ...able-D0JcWuTA.js => DataTable-DCEy-2Es.js} | 2 +- ...xDc.js => EstimatedCostMarker-BpsBXEjf.js} | 2 +- ...rBar-DuduzIn6.js => FilterBar-BS6lhcC1.js} | 2 +- ...ksTab-ovi_pavq.js => ForksTab-ryk22jFG.js} | 2 +- ...y-BkaNd4nH.js => IconActivity-Bf5MQINx.js} | 2 +- ...yBnJSK0.js => IconAlertCircle-h2zR9MWj.js} | 2 +- ...xIaEMpRY.js => IconArrowRight-lOOgkPXu.js} | 2 +- ...Up-CszCUTPq.js => IconArrowUp-D8NJ3QQF.js} | 2 +- .../static/react/assets/IconBolt-CWu2Wz0a.js | 6 + ...heck-DwMBTi_m.js => IconCheck-BQLu3HFV.js} | 2 +- ...eX-1-WYCT5P.js => IconCircleX-D7ABaoUn.js} | 2 +- ...lock-Dl-xdEIm.js => IconClock-CgB7iTL4.js} | 2 +- ...UHU_zj__.js => IconClockHour4-BUN27e3Z.js} | 2 +- ...nCode-DaQ_eMLh.js => IconCode-nzK0btv1.js} | 2 +- ...nCopy-DA8lvnPB.js => IconCopy-BOYWZx4M.js} | 2 +- ...t-C9y_jNvO.js => IconFileText-ChD_eRqq.js} | 2 +- ...nHash-Bsu2htWI.js => IconHash-cdeuBxnj.js} | 2 +- ...> IconPlayerSkipForwardFilled-DA8tH9T1.js} | 2 +- ...obot-BQwymFKA.js => IconRobot-DuCKb11z.js} | 2 +- ...izMI.js => IconSortDescending-DunfClYo.js} | 2 +- ...DvwC6rkL.js => IconTrendingUp-D1iuYAG9.js} | 2 +- ...nUser-ectHbzBi.js => IconUser-IjRNaThB.js} | 2 +- .../static/react/assets/Live-B7kSsFN1.js | 6 - .../static/react/assets/Live-BX6jEGZn.js | 1 + ...ab-Dcm_kMZs.js => MessagesTab-Dull7JZB.js} | 2 +- .../{Modal-Da9xsK-A.js => Modal-DkeyqO4J.js} | 2 +- ...rview-DRVBiWsj.js => Overview-BqyHxuZ_.js} | 2 +- ...ab-BptDgpTp.js => OverviewTab-CSNvLBfV.js} | 2 +- ...ab-DX5SA_Uq.js => PlaybackTab-0L9gLEFP.js} | 2 +- .../react/assets/ProjectDashboard-DAsn11K-.js | 7 + .../react/assets/ProjectDashboard-DRHQbkve.js | 7 - ...p-PiRoWNpI.js => ProviderChip-baymKnS_.js} | 2 +- .../{QATab-Dgj7TjI_.js => QATab-DUs6uRU9.js} | 2 +- ...hTab-BNg4qG1t.js => SearchTab-DsaZoPy9.js} | 2 +- ...ab-1seW1KtH.js => SessionsTab-NrDPSNfB.js} | 2 +- ...tings-Cnnpq-i1.js => Settings-BSujxVUW.js} | 2 +- ...agsTab-CBR6F8Z3.js => TagsTab-DSK8Zu2d.js} | 2 +- ...I.js => TokenCompositionDonut-DTEMWIwY.js} | 2 +- ...b-B6uk-r9G.js => WorktreesTab-B65wyswE.js} | 2 +- ...ldTab-Ck-altPN.js => YieldTab-BbHHGYlL.js} | 2 +- ...-CJWioGLA.js => dashboardTabs-C5ecR5YO.js} | 2 +- .../{index-DDMGE_ZF.js => index-DQluCO2S.js} | 4 +- stackunderflow/static/react/index.html | 2 +- tests/stackunderflow/hooks/test_proactive.py | 449 +++++++++++++++++- .../routes/test_patterns_route.py | 88 ++++ 64 files changed, 1240 insertions(+), 107 deletions(-) rename stackunderflow/static/react/assets/{AgentsTab-DJ-0Trzo.js => AgentsTab-BajlmbLz.js} (96%) rename stackunderflow/static/react/assets/{BookmarksTab-DAjJ72Pj.js => BookmarksTab-Cor0cmyo.js} (96%) rename stackunderflow/static/react/assets/{BudgetsTab-n3gfgUaU.js => BudgetsTab-BjHaaUsj.js} (98%) create mode 100644 stackunderflow/static/react/assets/CodingHealthTab-B02yOcgC.js delete mode 100644 stackunderflow/static/react/assets/CodingHealthTab-Cz4e1hEF.js rename stackunderflow/static/react/assets/{CommandsTab-D4oiPDTW.js => CommandsTab-DlkmMFl4.js} (93%) rename stackunderflow/static/react/assets/{CompareTab-CVqkwtRX.js => CompareTab-D60fXyc4.js} (98%) rename stackunderflow/static/react/assets/{ContextReplayTab-DjTyR19Y.js => ContextReplayTab-ixP9t9_Y.js} (98%) rename stackunderflow/static/react/assets/{CostTab-BAdmE1EC.js => CostTab-DCJQfNNf.js} (99%) rename stackunderflow/static/react/assets/{DataTable-D0JcWuTA.js => DataTable-DCEy-2Es.js} (95%) rename stackunderflow/static/react/assets/{EstimatedCostMarker-DkSj8xDc.js => EstimatedCostMarker-BpsBXEjf.js} (90%) rename stackunderflow/static/react/assets/{FilterBar-DuduzIn6.js => FilterBar-BS6lhcC1.js} (98%) rename stackunderflow/static/react/assets/{ForksTab-ovi_pavq.js => ForksTab-ryk22jFG.js} (96%) rename stackunderflow/static/react/assets/{IconActivity-BkaNd4nH.js => IconActivity-Bf5MQINx.js} (86%) rename stackunderflow/static/react/assets/{IconAlertCircle-DyBnJSK0.js => IconAlertCircle-h2zR9MWj.js} (89%) rename stackunderflow/static/react/assets/{IconArrowRight-xIaEMpRY.js => IconArrowRight-lOOgkPXu.js} (89%) rename stackunderflow/static/react/assets/{IconArrowUp-CszCUTPq.js => IconArrowUp-D8NJ3QQF.js} (94%) create mode 100644 stackunderflow/static/react/assets/IconBolt-CWu2Wz0a.js rename stackunderflow/static/react/assets/{IconCheck-DwMBTi_m.js => IconCheck-BQLu3HFV.js} (86%) rename stackunderflow/static/react/assets/{IconCircleX-1-WYCT5P.js => IconCircleX-D7ABaoUn.js} (88%) rename stackunderflow/static/react/assets/{IconClock-Dl-xdEIm.js => IconClock-CgB7iTL4.js} (88%) rename stackunderflow/static/react/assets/{IconClockHour4-UHU_zj__.js => IconClockHour4-BUN27e3Z.js} (89%) rename stackunderflow/static/react/assets/{IconCode-DaQ_eMLh.js => IconCode-nzK0btv1.js} (88%) rename stackunderflow/static/react/assets/{IconCopy-DA8lvnPB.js => IconCopy-BOYWZx4M.js} (92%) rename stackunderflow/static/react/assets/{IconFileText-C9y_jNvO.js => IconFileText-ChD_eRqq.js} (91%) rename stackunderflow/static/react/assets/{IconHash-Bsu2htWI.js => IconHash-cdeuBxnj.js} (89%) rename stackunderflow/static/react/assets/{IconPlayerSkipForwardFilled-DO-WPkWS.js => IconPlayerSkipForwardFilled-DA8tH9T1.js} (95%) rename stackunderflow/static/react/assets/{IconRobot-BQwymFKA.js => IconRobot-DuCKb11z.js} (93%) rename stackunderflow/static/react/assets/{IconSortDescending-nc0XizMI.js => IconSortDescending-DunfClYo.js} (90%) rename stackunderflow/static/react/assets/{IconTrendingUp-DvwC6rkL.js => IconTrendingUp-D1iuYAG9.js} (88%) rename stackunderflow/static/react/assets/{IconUser-ectHbzBi.js => IconUser-IjRNaThB.js} (94%) delete mode 100644 stackunderflow/static/react/assets/Live-B7kSsFN1.js create mode 100644 stackunderflow/static/react/assets/Live-BX6jEGZn.js rename stackunderflow/static/react/assets/{MessagesTab-Dcm_kMZs.js => MessagesTab-Dull7JZB.js} (96%) rename stackunderflow/static/react/assets/{Modal-Da9xsK-A.js => Modal-DkeyqO4J.js} (94%) rename stackunderflow/static/react/assets/{Overview-DRVBiWsj.js => Overview-BqyHxuZ_.js} (98%) rename stackunderflow/static/react/assets/{OverviewTab-BptDgpTp.js => OverviewTab-CSNvLBfV.js} (98%) rename stackunderflow/static/react/assets/{PlaybackTab-DX5SA_Uq.js => PlaybackTab-0L9gLEFP.js} (99%) create mode 100644 stackunderflow/static/react/assets/ProjectDashboard-DAsn11K-.js delete mode 100644 stackunderflow/static/react/assets/ProjectDashboard-DRHQbkve.js rename stackunderflow/static/react/assets/{ProviderChip-PiRoWNpI.js => ProviderChip-baymKnS_.js} (70%) rename stackunderflow/static/react/assets/{QATab-Dgj7TjI_.js => QATab-DUs6uRU9.js} (97%) rename stackunderflow/static/react/assets/{SearchTab-BNg4qG1t.js => SearchTab-DsaZoPy9.js} (97%) rename stackunderflow/static/react/assets/{SessionsTab-1seW1KtH.js => SessionsTab-NrDPSNfB.js} (98%) rename stackunderflow/static/react/assets/{Settings-Cnnpq-i1.js => Settings-BSujxVUW.js} (99%) rename stackunderflow/static/react/assets/{TagsTab-CBR6F8Z3.js => TagsTab-DSK8Zu2d.js} (97%) rename stackunderflow/static/react/assets/{TokenCompositionDonut-DsJdUSqI.js => TokenCompositionDonut-DTEMWIwY.js} (98%) rename stackunderflow/static/react/assets/{WorktreesTab-B6uk-r9G.js => WorktreesTab-B65wyswE.js} (98%) rename stackunderflow/static/react/assets/{YieldTab-Ck-altPN.js => YieldTab-BbHHGYlL.js} (97%) rename stackunderflow/static/react/assets/{dashboardTabs-CJWioGLA.js => dashboardTabs-C5ecR5YO.js} (99%) rename stackunderflow/static/react/assets/{index-DDMGE_ZF.js => index-DQluCO2S.js} (92%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad54baa..c5faea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Prescriptive cost (Optimize surface)** — `/api/optimize/prescriptions` turns waste findings into actions: model-routing recommendations built from per-model spend + v026 reasoning-token attribution (with dollar deltas priced through `compute_cost`, never invented), and a CLAUDE.md slimming preview (`POST /api/optimize/claudemd-preview`) that emits a unified diff + per-rule savings. Preview-only by construction — the server never writes user files (enforced by an AST source-scan test). - **Backup state capture** — `backup create` now copies the four critical `~/.stackunderflow` artifacts (`store.db`, `search_index.db`, `qa_pairs.db`, `tags.json`) into `/stackunderflow-state/` via the SQLite online-backup API. Previously a fresh backup failed its own `backup verify` and a restore silently lost search/Q&A/tags. - **Context-window replay (issue #96, beta)** — reconstruct what the model "saw" at a point in a session. `stackunderflow context-replay --at ` / `GET /api/context-replay/{session}?at=` return the ordered message sequence that had accumulated as the model's context up to that turn — each turn's role, a content preview, an estimated token footprint, its tool calls, and a running token total. Read-only + advisory (an unknown session is empty-but-valid, never an error); same-project fencing and a per-session read-through cache like `/api/forks`; `--json` emits the shared `stackunderflow.memory/1` agent-output envelope. A new beta **Context Replay** dashboard tab scrubs the `seq` cutoff and watches the token total grow. MVP semantics = "the session's message sequence up to `seq`" (harness-side context eviction is a later refinement). +- **Multi-device sync (issue #100, opt-in)** — sync your cost/usage **aggregates** (never raw transcripts) across machines through your own S3-compatible bucket, client-side-encrypted with `age` (zero-knowledge; the bucket sees only ciphertext). `stackunderflow sync init/push/pull/status` (import-guarded `[sync]` extra). Push writes only encrypted mart shards under this device's prefix (two-phase manifest commit, skip-if-unchanged); `pull` merges peers into a unified view at `GET /api/sync/overview?scope=all-devices` — aggregates union at the stable `(provider, slug)` grain, sessions deduped by id. Default-off and byte-identical when unconfigured; additive `v028`/`v029` migrations. +- **Proactive nudges (issue #97, opt-in, default-off)** — before a Bash command that has failed in a cluster of your past sessions (or after a recurring error signature), a hook injects a one-line, locally-derived heads-up ("`npm install` has failed in 3 recent sessions here — mostly Timeout"; "this error recurred in 4 sessions; the ones that moved past it ran X next"). A governance layer (per-session dedupe/cap, cross-session cooldown, dismiss-driven adaptive quieting; state in a file-locked JSON, never the DB) prevents nudge fatigue; hooks stay fast, LLM-free, silent-on-failure, and never block a tool. A "What almost bit me" panel in the Health tab surfaces recent would-have-fired nudges with dismiss controls. ### Fixed diff --git a/docs/specs/active-surfacing.md b/docs/specs/active-surfacing.md index 2258eb3..2196b8e 100644 --- a/docs/specs/active-surfacing.md +++ b/docs/specs/active-surfacing.md @@ -2,6 +2,13 @@ *Design spec for issue #97. Product design owned by the maintainer — this is a spec, not an implementation. No code, no schema migration, no version edits.* +> **Status:** Phase 0 (governance retrofit), Phase 1 (command-cluster nudge), and +> **Phase 2 (error-signature foresight + "What almost bit me" dashboard panel)** +> have shipped (`stackunderflow/hooks/proactive.py`, `hooks/handlers.py`+`templates.py`, +> `routes/patterns.py`, `stackunderflow-ui/.../CodingHealthTab.tsx`). Phase 3 +> (prompt-similarity) remains speculative / embeddings-gated. See §9 for the +> per-phase "as built" notes. + ## 0. Corrections to the issue body (read first) The issue was written before campaign #5/#6 shipped. Three parts of its implementation plan should change, and one framing is now inaccurate: @@ -27,10 +34,10 @@ Everything else in the issue (default-off, opt-in flag, no schema, "just a nudge | File-about-to-be-edited has failure/revert history | ✅ | `recall.py`, `inject._pre_tool_use_context` | Retrofit governance only | | Prompt lexically matches a past decision | ✅ | `inject._user_prompt_context` | — | | Project digest at session start | ✅ | `inject._session_start_context` | — | -| **Command about to run is in a failure cluster** | ❌ | `patterns.command_clusters` computed, surfaced nowhere live | **NEW — MVP** | -| **Recurring error signature + what fixed it last time** | ❌ | `patterns.error_signatures` + `resolution_hints`, surfaced nowhere live | **NEW — Phase 2** | +| **Command about to run is in a failure cluster** | ✅ | `proactive.command_cluster_block` (PreToolUse/Bash) | **SHIPPED — Phase 1** | +| **Recurring error signature + what fixed it last time** | ✅ | `proactive.error_signature_block` / `build_posttool_nudge` (PostToolUse/Bash) | **SHIPPED — Phase 2** | | **Prompt *semantically* similar to a `failed` session** | ❌ | needs embeddings (Spec 10) | **Speculative — Phase 3** | -| **Any anti-fatigue governance** (cap, dedupe, snooze, adaptive quieting) | ❌ | nothing exists | **NEW — MVP, the crux** | +| **Any anti-fatigue governance** (cap, dedupe, snooze, adaptive quieting) | ✅ | `proactive.should_surface` / `admit` + `proactive_state.json` | **SHIPPED — MVP, the crux** | **Verdict, stated plainly:** the file-edit nudge is done. The load-bearing new value in #97 is **(a) command-cluster triggers, (b) a governance layer the shipped hooks lack entirely, and (c) an optional dashboard surface for phrasing + tuning.** If we skip (b) and just add more triggers, we make the shipped feature *worse* (more noise, no throttle). Governance is not a "hard part" bullet at the end — it is the reason to do this spec. @@ -183,9 +190,42 @@ Wrap the *existing* `recall.py` output in the governance layer (§4): per-sessio **Phase 1 — Command-cluster nudge (the MVP new value).** Precompute/cache `command_clusters`; add the PreToolUse/Bash command-head lookup + template renderer to the recall path, under governance. Ship template-only. This is the "smallest genuinely-useful *new* nudge." -**Phase 2 — Error-signature foresight + dashboard panel.** +**Phase 2 — Error-signature foresight + dashboard panel. ✅ SHIPPED (campaign #8).** New PostToolUse/Bash nudge using `error_signatures` + `resolution_hints`. Add the "What almost bit me" section to `CodingHealthTab` with dismiss controls writing governance state. Optional Tier-2 LLM phrasing behind `proactive_llm_phrasing`. +*As built:* +- **Hook:** new id `stackunderflow-posttool-nudge` (PostToolUse, matcher `Bash`), + installed alongside recall/inject by `hooks install --inject`, dispatched via + `handlers.run` → `proactive.build_posttool_nudge`. It extracts the errored + `tool_response` body (`proactive._error_body_from_response` — stderr/error/ + `is_error` content only; a clean result is silent), normalises it with + `patterns._normalise_signature` **reused verbatim** for signature-key parity, + looks it up O(1) in the precomputed cache (`refresh_signal_cache` now also + emits `error_signatures`), and fires only when `session_count >= 2` **and** + `resolution_hints` is non-empty. Rides the *same* governance layer as Phase 1 + (`should_surface`/`admit`, dedupe/cap/cooldown/adaptive-quieting). Emits only + `hookSpecificOutput.additionalContext` — a PostToolUse hook can never block + the tool (it already ran); never a `decision`/deny; error/timeout → empty, + exit 0. +- **Type gate:** `error-signature` is a first-class type in `_KNOWN_TYPES`, so it + is governed by the `proactive_types` allowlist like the others. The shipped + `settings.py` default (`command-cluster,file-risk`) does **not** include it yet + (that default + the `--proactive` install flag are maintainer-owned), so until + the maintainer widens the default, enabling it is `proactive_enabled=1` **plus** + adding `error-signature` to `proactive_types` (or `STACKUNDERFLOW_PROACTIVE_TYPES`). +- **Dashboard:** `CodingHealthTab` gained a "What almost bit me" panel listing the + would-have-fired nudges (command-cluster + file-risk + error-signature) from the + existing `/api/patterns` report, each with **Dismiss** (fingerprint scope) and + **Don't show again** (type scope) controls. Both call the new + `POST /api/patterns/dismiss`, which computes the fingerprint with the *same* + `proactive.make_signal` Tier-1 uses and calls `proactive.record_dismissal` — + so a dashboard dismiss lands on the exact governance key the in-session gate + reads (round-trip verified). The endpoint writes only `proactive_state.json`, + never the store. +- **Deferred:** Tier-2 LLM phrasing (`proactive_llm_phrasing`) — the template + renderer is the shipped floor, as spec'd; LLM polish stays a later, opt-in, + dashboard-only add. + **Phase 3 — Prompt-similarity (only if embeddings ship and Phases 1–2 earn trust).** Semantic match vs. `failed` sessions on UserPromptSubmit. Highest annoyance risk; build last, guard hardest, or defer indefinitely. diff --git a/stackunderflow-ui/src/components/dashboard/CodingHealthTab.tsx b/stackunderflow-ui/src/components/dashboard/CodingHealthTab.tsx index efa29b7..ce00d41 100644 --- a/stackunderflow-ui/src/components/dashboard/CodingHealthTab.tsx +++ b/stackunderflow-ui/src/components/dashboard/CodingHealthTab.tsx @@ -1,16 +1,20 @@ -import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' import { IconActivityHeartbeat, IconAlertTriangle, + IconBolt, IconCircleCheck, IconFileText, IconHistory, IconRefresh, IconTerminal, + IconX, } from '@tabler/icons-react' -import { getPatterns, type PatternsSince } from '../../services/patterns' +import { dismissPattern, getPatterns, type PatternsSince } from '../../services/patterns' import type { + DismissPatternRequest, + NudgeType, PatternCommandCluster, PatternErrorSignature, PatternFileRisk, @@ -198,6 +202,162 @@ function CommandClusterRow({ cluster }: { cluster: PatternCommandCluster }) { ) } +// --------------------------------------------------------------------------- +// "What almost bit me" (spec 27 Phase 2) — the would-have-fired nudges, with +// Dismiss / "Don't show again" controls. Each dismiss writes the proactive +// governance state the *in-session* hooks read, so tuning here quiets Tier-1. +// The list is derived from the SAME `/api/patterns` report, filtered to each +// nudge type's relevance floor — a preview of what the hooks would surface. +// --------------------------------------------------------------------------- + +interface WouldFireNudge { + key: string // stable react key + optimistic-dismiss key + type: NudgeType + targetKey: string // command / signature / path — the fingerprint target + counts: [number, number] // the two salient counts Tier-1 fingerprints on + typeLabel: string + badgeColor: 'red' | 'orange' | 'yellow' + icon: React.ReactNode + text: string +} + +const MAX_ALMOST = 8 + +/** Derive the would-have-fired nudges from the report, newest/worst first. */ +function buildAlmostList(report: PatternsReportData): WouldFireNudge[] { + const items: WouldFireNudge[] = [] + + // Error-signature (Phase 2): recurred >= 2 sessions AND has a resolution hint. + for (const s of report.error_signatures) { + if (s.session_count < 2) continue + const hint = s.resolution_hints[0] + if (!hint) continue // no derivable "what fixed it" → the hook stays silent too + items.push({ + key: `error-signature:${s.category}:${s.signature}`, + type: 'error-signature', + targetKey: s.signature, + counts: [s.session_count, s.count], + typeLabel: 'error', + badgeColor: 'red', + icon: , + text: `Recurring "${s.signature}" (${s.session_count} sessions). Next step that worked: ${hint.action}.`, + }) + } + + // Command-cluster (Phase 1): failed >= 2 times across >= 2 sessions. + for (const c of report.command_clusters) { + if (c.failure_count < 2 || c.session_count < 2) continue + const cat = topCategory(c.categories) + items.push({ + key: `command-cluster:${c.command}`, + type: 'command-cluster', + targetKey: c.command, + counts: [c.failure_count, c.session_count], + typeLabel: 'command', + badgeColor: 'orange', + icon: , + text: `\`${c.command}\` failed in ${c.session_count} recent sessions${cat ? ` — mostly ${cat}` : ''}.`, + }) + } + + // File-risk (Phase 0): at least one failing session on the file. + for (const f of report.file_risk) { + if (f.failure_session_count < 1) continue + items.push({ + key: `file-risk:${f.path}`, + type: 'file-risk', + targetKey: f.path, + counts: [f.failure_count, f.failure_session_count], + typeLabel: 'file', + badgeColor: 'yellow', + icon: , + text: `${basename(f.path)} has failure history (${f.failure_session_count}/${f.touch_session_count} sessions${f.failure_rate !== null ? `, ${pct(f.failure_rate)}` : ''}).`, + }) + } + + return items.slice(0, MAX_ALMOST) +} + +function AlmostBitMe({ nudges }: { nudges: WouldFireNudge[] }) { + // Optimistic hiding — the governance write is advisory, and `/api/patterns` + // doesn't read governance state, so there's nothing to refetch. + const [dismissed, setDismissed] = useState>(() => new Set()) + const [mutedTypes, setMutedTypes] = useState>(() => new Set()) + const mutation = useMutation({ + mutationFn: (body: DismissPatternRequest) => dismissPattern(body), + }) + + if (nudges.length === 0) return null + const visible = nudges.filter(n => !dismissed.has(n.key) && !mutedTypes.has(n.type)) + + const dismissOne = (n: WouldFireNudge) => { + setDismissed(prev => new Set(prev).add(n.key)) + mutation.mutate({ type: n.type, scope: 'fingerprint', target_key: n.targetKey, counts: n.counts }) + } + const muteType = (n: WouldFireNudge) => { + setMutedTypes(prev => new Set(prev).add(n.type)) + mutation.mutate({ type: n.type, scope: 'type' }) + } + + return ( +
+
+ +

What almost bit me

+ + nudges that would fire in-session — dismiss to quiet them + +
+ {visible.length === 0 ? ( +
All caught up — nothing to review.
+ ) : ( +
+ {visible.map(n => ( +
+
+ {n.icon} +
+ {n.typeLabel} +

{n.text}

+
+
+
+ + +
+
+ ))} +
+ )} + {mutation.isError && ( +
+ Couldn't record that dismissal — it may reappear next time. +
+ )} +
+ ) +} + export default function CodingHealthTab({ projectName }: CodingHealthTabProps) { const [since, setSince] = useState('90d') @@ -215,6 +375,7 @@ export default function CodingHealthTab({ projectName }: CodingHealthTabProps) { (report.file_risk.length > 0 || report.error_signatures.length > 0 || report.command_clusters.length > 0) + const almost = useMemo(() => (report ? buildAlmostList(report) : []), [report]) return (
@@ -300,6 +461,8 @@ export default function CodingHealthTab({ projectName }: CodingHealthTabProps) {
)} + {!isLoading && !error && almost.length > 0 && } + {!isLoading && !error && report && !hasFindings && ( } diff --git a/stackunderflow-ui/src/services/patterns.ts b/stackunderflow-ui/src/services/patterns.ts index 5a884c7..00845e7 100644 --- a/stackunderflow-ui/src/services/patterns.ts +++ b/stackunderflow-ui/src/services/patterns.ts @@ -7,7 +7,11 @@ // mirrors `api.ts::fetchJson` exactly. // --------------------------------------------------------------------------- -import type { PatternsResponse } from '../types/api' +import type { + DismissPatternRequest, + DismissPatternResponse, + PatternsResponse, +} from '../types/api' const BASE = '/api' @@ -38,3 +42,20 @@ export async function getPatterns( if (project) params.set('project', project) return fetchJson(`${BASE}/patterns?${params}`) } + +/** + * Dismiss a proactive nudge from the "What almost bit me" panel. Writes the + * governance `dismissed` counter the in-session hooks read (Tier-2 → Tier-1), + * quieting the nudge. `scope: 'type'` mutes the whole kind; the default + * fingerprint scope mutes just this one — see `DismissPatternRequest`. The + * server only ever touches the governance JSON file, never the store. + */ +export async function dismissPattern( + body: DismissPatternRequest, +): Promise { + return fetchJson(`${BASE}/patterns/dismiss`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} diff --git a/stackunderflow-ui/src/types/api.ts b/stackunderflow-ui/src/types/api.ts index 2821ca0..5a4ee51 100644 --- a/stackunderflow-ui/src/types/api.ts +++ b/stackunderflow-ui/src/types/api.ts @@ -1420,6 +1420,29 @@ export interface PatternsResponse { report: PatternsReportData } +/** The nudge kinds the "What almost bit me" panel can dismiss — + * `proactive.TYPE_*` / `routes/patterns.py::_DISMISS_TYPES`. */ +export type NudgeType = 'command-cluster' | 'file-risk' | 'error-signature' + +/** Body for `POST /api/patterns/dismiss` — `routes/patterns.py::DismissRequest`. + * + * `scope: 'type'` mutes the whole kind; `scope: 'fingerprint'` (default) mutes + * this specific nudge. For fingerprint scope, `target_key` + `counts` are fed + * through the same signal builder Tier-1 uses, so the dismissed fingerprint is + * byte-identical to the one the in-session hook governance reads. */ +export interface DismissPatternRequest { + type: NudgeType + scope?: 'fingerprint' | 'type' + target_key?: string + counts?: [number, number] +} + +export interface DismissPatternResponse { + ok: boolean + scope: 'fingerprint' | 'type' + dismissed: string +} + // --------------------------------------------------------------------------- // Campaign #7 — prescriptive cost. Mirrors `reports/prescribe.py` payloads // served by GET /api/optimize/prescriptions and POST diff --git a/stackunderflow/hooks/_install.py b/stackunderflow/hooks/_install.py index 75e6f19..d2a7c77 100644 --- a/stackunderflow/hooks/_install.py +++ b/stackunderflow/hooks/_install.py @@ -285,11 +285,12 @@ def _add_our_hooks(settings: dict, *, capture_content: bool, inject: bool) -> di """Append our canonical matcher-groups to each event array (creating as needed). Always installs the four capture hooks; with ``inject`` also installs the - three injection hooks plus the active-recall hook. ``UserPromptSubmit`` - then carries two of our matcher-groups (a capture hook and an injection - hook) and ``PreToolUse`` carries two (the in-process injection hook and - the recall hook) — Claude Code runs every hook registered for an event, - so they coexist cleanly. + three injection hooks, the active-recall hook, and the proactive-nudge hook. + ``UserPromptSubmit`` then carries two of our matcher-groups (a capture hook + and an injection hook), ``PreToolUse`` carries two (the in-process injection + hook and the recall hook), and ``PostToolUse`` carries two (the capture + recorder and the proactive nudge) — Claude Code runs every hook registered + for an event, so they coexist cleanly. """ new = json.loads(json.dumps(settings)) hooks = new.setdefault("hooks", {}) @@ -309,6 +310,8 @@ def _append(event: str, group: dict) -> None: _append(event, templates.inject_matcher_group(event)) for event in templates.RECALL_EVENT_HOOK_IDS: _append(event, templates.recall_matcher_group(event)) + for event in templates.NUDGE_EVENT_HOOK_IDS: + _append(event, templates.nudge_matcher_group(event)) return new diff --git a/stackunderflow/hooks/handlers.py b/stackunderflow/hooks/handlers.py index c4f11a9..18e1eb4 100644 --- a/stackunderflow/hooks/handlers.py +++ b/stackunderflow/hooks/handlers.py @@ -94,10 +94,11 @@ def run(hook_id: str, payload: dict | None, *, capture_content: bool = False) -> Dispatches on *hook_id*. The four capture ids record a ``captured_events`` row (or no-op). The three ``stackunderflow-inject-*`` ids route to - :mod:`stackunderflow.hooks.inject`, and ``stackunderflow-pretool-recall`` - routes to :mod:`stackunderflow.hooks.recall`; both write a - context-injection JSON envelope to stdout instead. An unknown id is a - no-op. Any exception (malformed payload, store unavailable, …) is logged + :mod:`stackunderflow.hooks.inject`, ``stackunderflow-pretool-recall`` routes + to :mod:`stackunderflow.hooks.recall`, and ``stackunderflow-posttool-nudge`` + routes to :func:`stackunderflow.hooks.proactive.build_posttool_nudge`; those + write a context-injection JSON envelope to stdout instead. An unknown id is + a no-op. Any exception (malformed payload, store unavailable, …) is logged at DEBUG and swallowed — neither a recorder nor an injector may make Claude Code stumble. """ @@ -117,6 +118,13 @@ def run(hook_id: str, payload: dict | None, *, capture_content: bool = False) -> if output: sys.stdout.write(output if output.endswith("\n") else output + "\n") return 0 + if hook_id in templates.NUDGE_HOOK_IDS: + from stackunderflow.hooks import proactive + + output = proactive.build_posttool_nudge(hook_id, payload) + if output: + sys.stdout.write(output if output.endswith("\n") else output + "\n") + return 0 kind, sanitised = _classify(hook_id, payload, capture_content=capture_content) if kind is None: return 0 # nothing worth recording (success / non-correction / unknown hook) diff --git a/stackunderflow/hooks/proactive.py b/stackunderflow/hooks/proactive.py index 11df9e5..519542a 100644 --- a/stackunderflow/hooks/proactive.py +++ b/stackunderflow/hooks/proactive.py @@ -25,6 +25,18 @@ :func:`refresh_signal_cache`), applies the relevance floor + governance, and renders one deterministic advisory line. +* **Phase 2 (error-signature foresight)** — :func:`error_signature_block` runs on + a *PostToolUse*/Bash *errored* result: it normalises the error body (via + ``patterns._normalise_signature``, reused verbatim for signature-key parity), + looks the signature up in the same O(1) cache, and — when it recurred across + ``>= 2`` sessions *and* the mining derived non-empty ``resolution_hints`` — + emits "this recurred in N sessions; the ones that moved past it ran X next". + :func:`build_posttool_nudge` wraps it in the ``hookSpecificOutput`` envelope + for the ``stackunderflow-posttool-nudge`` hook id. It rides the *same* + governance layer as Phase 1 (``error-signature`` is a first-class type in + ``proactive_types``); only ever advisory ``additionalContext`` — a PostToolUse + hook can never block the tool, which has already run. + Invariants (this runs inside users' live sessions — non-negotiable): * **Opt-in, off by default.** ``proactive_enabled`` defaults false. When it is @@ -76,7 +88,8 @@ # The nudge type ids this module understands (mirrors ``proactive_types``). TYPE_COMMAND_CLUSTER = "command-cluster" TYPE_FILE_RISK = "file-risk" -_KNOWN_TYPES = frozenset({TYPE_COMMAND_CLUSTER, TYPE_FILE_RISK}) +TYPE_ERROR_SIGNATURE = "error-signature" +_KNOWN_TYPES = frozenset({TYPE_COMMAND_CLUSTER, TYPE_FILE_RISK, TYPE_ERROR_SIGNATURE}) # Relevance floor: a cluster's last failure must be at most this many days old # for the nudge to be "in the moment". Mirrors ``patterns.DEFAULT_SINCE_DAYS`` @@ -85,6 +98,11 @@ # ``patterns`` import for non-Bash fires. _RECENT_DAYS = 90 +# Error-signature floor: recurred in at least this many DISTINCT sessions. +# Mirrors ``patterns.MIN_RECURRENCE_SESSIONS`` (kept local for the same reason +# as ``_RECENT_DAYS``) — a one-session error is a retry loop, not a pattern. +_MIN_RECURRENCE_SESSIONS = 2 + # Bounds so neither JSON file can grow without limit (LRU-style eviction). _MAX_SESSIONS = 256 _MAX_COOLDOWNS = 1024 @@ -92,6 +110,8 @@ _MAX_PROJECTS_CACHED = 128 _MAX_CLUSTERS_PER_PROJECT = 200 _MAX_FILE_RISK_PER_PROJECT = 200 +_MAX_SIGNATURES_PER_PROJECT = 200 +_MAX_HINTS_CACHED = 3 # File-lock acquisition budget. A short spin, then a stale-lock breaker — a hook # must never wedge on a leaked lock. @@ -99,8 +119,9 @@ _LOCK_SPIN_S = 0.01 _LOCK_STALE_S = 10.0 -# Rendered command-cluster block is one line — capped defensively. +# Rendered command-cluster / error-signature block is one line — capped defensively. _CMD_MAX_CHARS = 600 +_SIG_MAX_CHARS = 600 _SIGNAL_CACHE_VERSION = 1 @@ -398,9 +419,13 @@ def _record_fire(state: dict, signal: Signal, policy: Policy, now: datetime) -> def record_dismissal(key: str, *, now: datetime | None = None) -> None: """Register a dashboard 'don't show this again' for a type or a fingerprint. - The Tier-2 dismiss primitive (the retrospective panel calls this; not wired - to a route in the MVP). Increments the ``dismissed`` counter that - :func:`should_surface` reads for adaptive quieting. Never raises. + The Tier-2 dismiss primitive, wired to ``POST /api/patterns/dismiss`` (the + "What almost bit me" panel). *key* is either a nudge type id (mutes the + whole kind) or a :attr:`Signal.fingerprint` (mutes that specific nudge) — + the route computes the fingerprint with the same :func:`make_signal` Tier-1 + uses, so a dashboard dismiss lands on the exact key ``should_surface`` + reads. Increments the ``dismissed`` counter that drives adaptive quieting. + Writes only the governance JSON, never the store. Never raises. """ now = now or _utcnow() try: @@ -509,6 +534,12 @@ def _top_category(categories: Any) -> str | None: def _lookup_cluster(slug: str, key: str) -> dict | None: """O(1) read of one cluster from the precomputed cache; ``None`` if absent.""" + return _lookup_signal(slug, "command_clusters", key) + + +def _lookup_signal(slug: str, family: str, key: str) -> dict | None: + """O(1) read of one entry from a cache family (``command_clusters`` / + ``error_signatures``) for *slug*; ``None`` when the cache/family/key is absent.""" cache = _read_json(_signal_path()) projects = cache.get("projects") if not isinstance(projects, dict): @@ -516,11 +547,166 @@ def _lookup_cluster(slug: str, key: str) -> dict | None: entry = projects.get(slug) if not isinstance(entry, dict): return None - clusters = entry.get("command_clusters") - if not isinstance(clusters, dict): + family_map = entry.get(family) + if not isinstance(family_map, dict): + return None + hit = family_map.get(key) + return hit if isinstance(hit, dict) else None + + +# ── the error-signature nudge (Phase 2, PostToolUse/Bash) ──────────────────── + + +def build_posttool_nudge(hook_id: str, payload: dict | None) -> str: + """Return the PostToolUse injection envelope for an error-signature nudge, or ``""``. + + The Tier-1 surface for Phase 2 (hook id ``stackunderflow-posttool-nudge``, + dispatched by ``handlers.run``). Mirrors ``recall.build_recall``: silent on + *any* failure, exit-0 caller. Only advisory ``additionalContext`` is ever + produced — never a ``decision``/deny (a PostToolUse hook cannot block the + tool, which has already run). Inert unless proactive is in *governed* mode + (opt-in); the env kill-switch silences it.""" + try: + payload = payload if isinstance(payload, dict) else {} + from stackunderflow.hooks import templates + + event = templates.HOOK_ID_EVENTS.get(hook_id) + if event is None or hook_id not in templates.NUDGE_HOOK_IDS: + return "" + if mode() != "governed": + return "" # off (kill-switch) or passthrough (default) → no new nudge + text = error_signature_block(payload) + if not text.strip(): + return "" + return json.dumps({"hookSpecificOutput": {"hookEventName": event, "additionalContext": text}}) + except Exception: # noqa: BLE001 - a nudge must never disrupt the agent + logger.debug("proactive.build_posttool_nudge swallowed an error", exc_info=True) + return "" + + +def error_signature_block(payload: dict, *, now: datetime | None = None) -> str: + """Advisory line for a Bash error whose normalised signature recurs, or ``""``. + + Fires on a PostToolUse/Bash *errored* result whose ``_normalise_signature`` + matches a mined :class:`~stackunderflow.reports.patterns.ErrorSignature` + with non-empty ``resolution_hints`` and ``session_count >= 2``, under the + same governance as Phase 1. O(1) cache lookup; never a live scan, never + raises.""" + try: + if not isinstance(payload, dict) or payload.get("tool_name") != "Bash": + return "" + body = _error_body_from_response(payload) + if not body: + return "" # a clean result or no extractable error text — stay silent + slug = _slug_from_cwd(payload.get("cwd")) + if not slug: + return "" + + from stackunderflow.reports.patterns import _normalise_signature + + signature = _normalise_signature(body) # VERBATIM reuse — signature-key parity + sig = _lookup_signal(slug, "error_signatures", signature) + if sig is None: + return "" + + now = now or _utcnow() + session_count = _as_int(sig.get("session_count"), 0) + hints = sig.get("resolution_hints") + has_hints = isinstance(hints, list) and len(hints) > 0 + eligible = session_count >= _MIN_RECURRENCE_SESSIONS and has_hints + signal = make_signal( + TYPE_ERROR_SIGNATURE, + signature, + _session_id(payload), + (session_count, _as_int(sig.get("count"), 0)), + eligible=eligible, + ) + if not admit(signal, now=now): + return "" + return _render_error_signature(sig, signature) + except Exception: # noqa: BLE001 - a nudge must never disrupt the agent + logger.debug("proactive.error_signature_block swallowed an error", exc_info=True) + return "" + + +def _render_error_signature(sig: dict, signature: str) -> str: + """One deterministic advisory line for an error-signature nudge (spec §3.C).""" + session_count = _as_int(sig.get("session_count"), 0) + sess_word = "session" if session_count == 1 else "sessions" + example = sig.get("example") + shown = example if isinstance(example, str) and example.strip() else signature + shown = _one_line(shown, 160) + text = f'[StackUnderflow memory] This error recurred in {session_count} {sess_word}: "{shown}".' + action = _top_hint_action(sig.get("resolution_hints")) + if action: + text += f" The sessions that moved past it ran `{action}` next." + if len(text) > _SIG_MAX_CHARS: + text = text[: max(1, _SIG_MAX_CHARS - 1)].rstrip() + "…" + return text + + +def _top_hint_action(hints: Any) -> str | None: + """The highest-count resolution-hint action (cache preserves the miner's order).""" + if not isinstance(hints, list) or not hints: return None - cluster = clusters.get(key) - return cluster if isinstance(cluster, dict) else None + first = hints[0] + if isinstance(first, dict) and isinstance(first.get("action"), str) and first["action"].strip(): + return first["action"].strip() + return None + + +# Error-bearing string fields on a PostToolUse ``tool_response`` (the shapes the +# capture handler already probes for a failure). Ordered stderr-first — that's +# where a Bash failure's signature line lives. +_ERR_STRING_FIELDS = ("stderr", "error", "message") + + +def _content_text(content: Any) -> str: + """Flatten a tool_result-style ``content`` (str | list of ``{text}``) to one string.""" + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [b.get("text", "") for b in content if isinstance(b, dict) and isinstance(b.get("text"), str)] + return " ".join(p for p in parts if p).strip() + return "" + + +def _error_body_from_response(payload: dict) -> str: + """Best-effort error text from a PostToolUse ``tool_response``, or ``""``. + + Returns text *only* when the response carries an error signal — an + ``stderr`` / ``error`` / ``message`` string, an ``is_error`` tool_result, + or an explicit ``success: false``. A clean response (stdout only) yields + ``""`` → the handler stays silent. The first meaningful line of the + returned text is what ``patterns._normalise_signature`` keys on, matching + how the miner saw the errored tool_result content.""" + resp = payload.get("tool_response") + if isinstance(resp, str): + return resp.strip() + if isinstance(resp, dict): + lower = {str(k).lower(): v for k, v in resp.items()} + for k in _ERR_STRING_FIELDS: + v = lower.get(k) + if isinstance(v, str) and v.strip(): + return v.strip() + if lower.get("is_error") is True or lower.get("success") is False: + text = _content_text(lower.get("content")) + if text: + return text + return "" + if isinstance(resp, list): + parts: list[str] = [] + errored = False + for b in resp: + if not isinstance(b, dict): + continue + if b.get("is_error"): + errored = True + t = b.get("text") if isinstance(b.get("text"), str) else _content_text(b.get("content")) + if t: + parts.append(t) + return "\n".join(parts).strip() if (errored and parts) else "" + return "" # ── signal cache precompute (ingest side) ─────────────────────────────────── @@ -561,6 +747,7 @@ def refresh_signal_cache(conn: "sqlite3.Connection", slugs: set[str] | list[str] "generated_at": now_iso, "command_clusters": _clusters_map(report.get("command_clusters")), "file_risk": _file_risk_map(report.get("file_risk")), + "error_signatures": _error_signatures_map(report.get("error_signatures")), } cache = { "version": _SIGNAL_CACHE_VERSION, @@ -595,8 +782,8 @@ def _clusters_map(clusters: Any) -> dict: def _file_risk_map(file_risk: Any) -> dict: - """``file_risk`` list → ``{path: trimmed risk}``. Cached for Tier-2 / Phase 2; - the MVP hook path does not read it (file-risk stays on the recall CLI path).""" + """``file_risk`` list → ``{path: trimmed risk}``. Cached for Tier-2; + the hook path does not read it (file-risk stays on the recall CLI path).""" out: dict[str, dict] = {} if not isinstance(file_risk, list): return out @@ -614,6 +801,41 @@ def _file_risk_map(file_risk: Any) -> dict: return out +def _error_signatures_map(signatures: Any) -> dict: + """``error_signatures`` list → ``{signature: trimmed signature}`` (O(1) lookup). + + Keyed by the normalised ``signature`` string — the exact key + ``patterns._normalise_signature`` produces — so the PostToolUse handler can + look up a fresh error's normalised signature directly (Phase 2). Only the + fields the nudge renders / governs on are kept, and ``resolution_hints`` is + trimmed to the top few actions.""" + out: dict[str, dict] = {} + if not isinstance(signatures, list): + return out + for s in signatures[:_MAX_SIGNATURES_PER_PROJECT]: + if not isinstance(s, dict): + continue + key = s.get("signature") + if not isinstance(key, str) or not key: + continue + hints: list[dict] = [] + raw_hints = s.get("resolution_hints") + if isinstance(raw_hints, list): + for h in raw_hints[:_MAX_HINTS_CACHED]: + if isinstance(h, dict) and isinstance(h.get("action"), str) and h.get("action").strip(): + hints.append({"action": h["action"].strip(), "count": _as_int(h.get("count"), 0)}) + out[key] = { + "signature": key, + "category": s.get("category") if isinstance(s.get("category"), str) else "", + "session_count": _as_int(s.get("session_count"), 0), + "count": _as_int(s.get("count"), 0), + "resolution_hints": hints, + "last_ts": s.get("last_ts") if isinstance(s.get("last_ts"), str) else None, + "example": s.get("example") if isinstance(s.get("example"), str) else "", + } + return out + + def _cap_projects(projects: dict) -> dict: """Bound the cache to the most-recently-generated projects.""" if len(projects) <= _MAX_PROJECTS_CACHED: @@ -775,6 +997,14 @@ def _session_id(payload: dict) -> str: return sid if isinstance(sid, str) and sid else "" +def _one_line(text: Any, limit: int) -> str: + """Collapse whitespace and clip *text* to *limit* chars with an ellipsis.""" + one = " ".join(str(text).split()) + if len(one) <= limit: + return one + return one[: max(1, limit - 1)].rstrip() + "…" + + def _utcnow() -> datetime: return datetime.now(UTC) diff --git a/stackunderflow/hooks/templates.py b/stackunderflow/hooks/templates.py index a967569..f2ff0bb 100644 --- a/stackunderflow/hooks/templates.py +++ b/stackunderflow/hooks/templates.py @@ -2,8 +2,9 @@ One place defines: -* the hook ids — four *capture* hooks, three *injection* hooks, and one - *active-recall* hook — and which Claude Code lifecycle event each binds to, +* the hook ids — four *capture* hooks, three *injection* hooks, one + *active-recall* hook, and one *proactive-nudge* hook — and which Claude Code + lifecycle event each binds to, * the *portable* command form (``stackunderflow hooks run `` — never an absolute path, so the entry survives a venv move; see hard constraint #6), * the matchers we use (``PostToolUse`` capture is scoped to ``Bash``, the tool @@ -54,23 +55,38 @@ "PreToolUse": "stackunderflow-pretool-recall", } +# Proactive-nudge hook (campaign #8 / spec 27 Phase 2) — installed alongside the +# injection + recall hooks by ``hooks install --inject``. Runs *after* an +# errored Bash tool call and injects an advisory line when the error's +# normalised signature matches a mined recurring ``ErrorSignature`` with +# resolution hints ("this recurred in N sessions; the ones that moved past it +# ran X next"). It gets its own id + handler path (``proactive.build_posttool_nudge``) +# because it fires on ``PostToolUse`` — a different event than recall (PreToolUse) +# and the capture hook it shares the event with (which RECORDS, not injects). +NUDGE_EVENT_HOOK_IDS: dict[str, str] = { + "PostToolUse": "stackunderflow-posttool-nudge", +} + # hook id → Claude Code event, for every hook we own (capture + injection + -# recall). Keyed by hook id because that *is* unique — events are not -# (UserPromptSubmit maps to two, PreToolUse to two). ``parse_hook_command`` -# uses this to recognise our ids. +# recall + nudge). Keyed by hook id because that *is* unique — events are not +# (UserPromptSubmit maps to two, PreToolUse to two, PostToolUse to two: the +# capture recorder and the proactive nudge). ``parse_hook_command`` uses this +# to recognise our ids. HOOK_ID_EVENTS: dict[str, str] = { **{hid: ev for ev, hid in EVENT_HOOK_IDS.items()}, **{hid: ev for ev, hid in INJECT_EVENT_HOOK_IDS.items()}, **{hid: ev for ev, hid in RECALL_EVENT_HOOK_IDS.items()}, + **{hid: ev for ev, hid in NUDGE_EVENT_HOOK_IDS.items()}, } # Capture hook ids (the original four). Kept as ``HOOK_IDS`` for backward # compatibility — re-exported from ``handlers`` and used across the install path -# and tests. The injection / recall ids and the union get their own names. +# and tests. The injection / recall / nudge ids and the union get their own names. HOOK_IDS: tuple[str, ...] = tuple(EVENT_HOOK_IDS.values()) INJECT_HOOK_IDS: tuple[str, ...] = tuple(INJECT_EVENT_HOOK_IDS.values()) RECALL_HOOK_IDS: tuple[str, ...] = tuple(RECALL_EVENT_HOOK_IDS.values()) -ALL_HOOK_IDS: tuple[str, ...] = HOOK_IDS + INJECT_HOOK_IDS + RECALL_HOOK_IDS +NUDGE_HOOK_IDS: tuple[str, ...] = tuple(NUDGE_EVENT_HOOK_IDS.values()) +ALL_HOOK_IDS: tuple[str, ...] = HOOK_IDS + INJECT_HOOK_IDS + RECALL_HOOK_IDS + NUDGE_HOOK_IDS # Matchers scope a hook to specific tools. ``PostToolUse`` capture is scoped to # ``Bash`` (the clean non-zero-exit failure signal); firing on every tool would @@ -90,6 +106,11 @@ RECALL_EVENT_MATCHERS: dict[str, str] = { "PreToolUse": "Edit|Write|Bash", } +# The proactive nudge fires after a Bash call (its error signature is what we +# match) — same Bash scope as the capture hook, a distinct id/handler. +NUDGE_EVENT_MATCHERS: dict[str, str] = { + "PostToolUse": "Bash", +} # Each hook command is ``stackunderflow hooks run `` optionally followed by # ``--capture-content``. This regex pulls the id (group ``hook_id``) and the @@ -187,14 +208,23 @@ def recall_matcher_group(event: str) -> dict: return _matcher_group(RECALL_EVENT_HOOK_IDS[event], RECALL_EVENT_MATCHERS.get(event)) +def nudge_matcher_group(event: str) -> dict: + """The matcher-group ``install --inject`` appends for the *proactive-nudge* hook. + + Never carries ``--capture-content`` — it injects an advisory line, it + records nothing. + """ + return _matcher_group(NUDGE_EVENT_HOOK_IDS[event], NUDGE_EVENT_MATCHERS.get(event)) + + def canonical_hooks_block(*, capture_content: bool = False, inject: bool = False) -> dict: """The full ``hooks`` mapping ``install`` would write into a fresh file. - With ``inject=True`` the three injection hooks and the active-recall hook - are merged in alongside the capture hooks; ``UserPromptSubmit`` — which - carries a capture and an injection hook — ends up with both - matcher-groups, and ``PreToolUse`` carries the injection *and* recall - groups. + With ``inject=True`` the three injection hooks, the active-recall hook, and + the proactive-nudge hook are merged in alongside the capture hooks; + ``UserPromptSubmit`` — which carries a capture and an injection hook — ends + up with both matcher-groups, ``PreToolUse`` carries the injection *and* + recall groups, and ``PostToolUse`` carries the capture *and* nudge groups. """ block: dict = { event: [matcher_group(event, capture_content=capture_content)] @@ -205,4 +235,6 @@ def canonical_hooks_block(*, capture_content: bool = False, inject: bool = False block.setdefault(event, []).append(inject_matcher_group(event)) for event in RECALL_EVENT_HOOK_IDS: block.setdefault(event, []).append(recall_matcher_group(event)) + for event in NUDGE_EVENT_HOOK_IDS: + block.setdefault(event, []).append(nudge_matcher_group(event)) return block diff --git a/stackunderflow/routes/patterns.py b/stackunderflow/routes/patterns.py index 15e39f0..27e1157 100644 --- a/stackunderflow/routes/patterns.py +++ b/stackunderflow/routes/patterns.py @@ -53,6 +53,11 @@ No dollar figures — this endpoint carries no currency payload. Every list is deterministically ordered and capped, so the same store always renders the same panel. + +``POST /api/patterns/dismiss`` is the write companion (spec 27 Phase 2): the +"What almost bit me" panel calls it to record a dismissal into the proactive +nudge governance state, so the in-session Tier-1 hooks quiet down. It writes +only the governance JSON file, never the store — see :func:`dismiss_pattern`. """ from __future__ import annotations @@ -62,6 +67,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel import stackunderflow.deps as deps from stackunderflow.reports.patterns import DEFAULT_SINCE_DAYS, MAX_SINCE_DAYS, mine_patterns @@ -69,6 +75,11 @@ router = APIRouter() +# Nudge type ids the dismiss endpoint accepts — the "What almost bit me" panel +# dismisses one of these. Mirrors ``proactive.TYPE_*`` (kept local so this route +# has no import-time dependency on the hooks package). +_DISMISS_TYPES = frozenset({"command-cluster", "file-risk", "error-signature"}) + _SINCE_QUERY = Query("90d", description="Window as d, e.g. 7d | 30d | 90d (max 365d)") _PROJECT_QUERY = Query(None, description="Project slug; omit for the active project / whole store") @@ -140,3 +151,67 @@ async def get_patterns( "since": f"{days}d", "report": report, } + + +# ── dismiss (Tier-2 → governance write) ────────────────────────────────────── + + +class DismissRequest(BaseModel): + """Body for ``POST /api/patterns/dismiss`` (the "What almost bit me" panel). + + * ``type`` — one of ``command-cluster`` / ``file-risk`` / ``error-signature``. + * ``scope`` — ``"fingerprint"`` (default; mute *this* specific nudge) or + ``"type"`` (mute the whole kind). + * ``target_key`` / ``counts`` — only for fingerprint scope: the nudge's + target (normalised command / signature / path) and its two salient counts. + The route feeds these through the *same* ``proactive.make_signal`` Tier-1 + uses, so the dismissed fingerprint is byte-identical to the one the hook's + governance gate reads. + """ + + type: str + scope: str = "fingerprint" + target_key: str | None = None + counts: list[int] | None = None + + +def _coerce_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +@router.post("/api/patterns/dismiss") +async def dismiss_pattern(body: DismissRequest) -> dict: + """Record a dashboard dismissal into the proactive governance state. + + Writes the ``feedback`` ``dismissed`` counter that + :func:`stackunderflow.hooks.proactive.record_dismissal` bumps and + :func:`~stackunderflow.hooks.proactive.should_surface` reads for adaptive + quieting (spec §4.3 / §5 Tier-2), so Tier-1 quiets accordingly. Only ever + touches the governance JSON file at ``~/.stackunderflow/proactive_state.json`` + — never ``store.db``. Advisory: an unknown type is a 400, but a governance + write hiccup is swallowed (``record_dismissal`` never raises). + """ + from stackunderflow.hooks import proactive + + sig_type = body.type.strip().lower() if isinstance(body.type, str) else "" + if sig_type not in _DISMISS_TYPES: + raise HTTPException(status_code=400, detail=f"Unknown nudge type '{body.type}'.") + + scope = (body.scope or "fingerprint").strip().lower() + if scope == "type" or not body.target_key: + # Mute the whole kind: the dismissal key IS the type id. + proactive.record_dismissal(sig_type) + return {"ok": True, "scope": "type", "dismissed": sig_type} + + counts = [_coerce_int(c) for c in (body.counts or [])][:2] + while len(counts) < 2: + counts.append(0) + # Same signal Tier-1 builds → same fingerprint (session_id is not part of it). + signal = proactive.make_signal( + sig_type, body.target_key, None, (counts[0], counts[1]), eligible=True + ) + proactive.record_dismissal(signal.fingerprint) + return {"ok": True, "scope": "fingerprint", "dismissed": signal.fingerprint} diff --git a/stackunderflow/static/react/assets/AgentsTab-DJ-0Trzo.js b/stackunderflow/static/react/assets/AgentsTab-BajlmbLz.js similarity index 96% rename from stackunderflow/static/react/assets/AgentsTab-DJ-0Trzo.js rename to stackunderflow/static/react/assets/AgentsTab-BajlmbLz.js index a4eebb1..0dbaf85 100644 --- a/stackunderflow/static/react/assets/AgentsTab-DJ-0Trzo.js +++ b/stackunderflow/static/react/assets/AgentsTab-BajlmbLz.js @@ -1 +1 @@ -import{r as l,u as b,j as e}from"./react-vendor-B7v2HPaI.js";import{a5 as R,a6 as z,L as N,j as T,a7 as M,a8 as U}from"./index-DDMGE_ZF.js";import{c as j,b as L,f as F}from"./format-Co_unrac.js";import{E as S}from"./EmptyState-o0gibvhZ.js";import{h as D}from"./dashboardTabs-CJWioGLA.js";import{I as A}from"./IconRobot-BQwymFKA.js";import{a as P,I as q}from"./IconUser-ectHbzBi.js";import{I as K}from"./IconHash-Bsu2htWI.js";import{I as C}from"./IconClock-Dl-xdEIm.js";function _(t){if(!t)return"—";try{return new Date(t).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return t}}function p(t,a=8){return t?t.length>a?`${t.slice(0,a)}…`:t:"—"}function Z({projectName:t}={}){var v;const a=R(typeof window<"u"?window.location.search:""),[r,u]=l.useState(a.session),[i,h]=l.useState(a.agent);l.useEffect(()=>{if(typeof window>"u")return;z(window.location.search,{session:r,agent:i});const s=new URL(window.location.href),d=new URLSearchParams(s.search);r?d.set("session",r):d.delete("session"),i?d.set("agent",i):d.delete("agent");const o=d.toString(),c=`${s.pathname}${o?`?${o}`:""}${s.hash}`,y=`${window.location.pathname}${window.location.search}${window.location.hash}`;c!==y&&window.history.replaceState({},"",c)},[r,i]);const x=b({queryKey:["agent-teams","list",t??"__all__"],queryFn:()=>M(50,t??null)}),g=b({queryKey:["agent-teams","graph",r],queryFn:()=>U(r),enabled:!!r}),m=((v=x.data)==null?void 0:v.teams)??[],n=g.data??null;l.useEffect(()=>{!r&&m.length>0&&u(m[0].session_id)},[m,r]);const w=l.useMemo(()=>n?i?n.agents.find(s=>s.session_id===i)??n.lead:n.lead:null,[n,i]),$=l.useRef(null),E=l.useCallback(s=>{if(!n)return;const d=[n.lead,...n.agents],o=d.findIndex(k=>i?k.session_id===i:k.is_lead);if(o<0)return;let c=o;if(s.key==="j"||s.key==="ArrowDown")c=Math.min(d.length-1,o+1);else if(s.key==="k"||s.key==="ArrowUp")c=Math.max(0,o-1);else return;s.preventDefault();const y=d[c];h(y.is_lead?null:y.session_id)},[n,i]);return x.isLoading?e.jsx(N,{message:"Loading agent teams..."}):x.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load agent teams:"," ",x.error instanceof Error?x.error.message:"Unknown error"]}):m.length===0?e.jsx(S,{icon:e.jsx(D,{size:40}),title:"No agent teams yet",description:"When Claude Code spawns parallel sub-agents (via the TeamCreate tool), the dependency graph will show up here."}):e.jsxs("div",{ref:$,onKeyDown:E,tabIndex:0,className:"grid grid-cols-1 lg:grid-cols-12 gap-4 outline-none","data-testid":"agents-tab",children:[e.jsxs("aside",{className:"lg:col-span-5 xl:col-span-4 space-y-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 px-1",children:"Recent agent teams"}),e.jsx("div",{className:"space-y-1",children:m.map(s=>{const d=s.session_id===r;return e.jsxs("button",{onClick:()=>{u(s.session_id),h(null)},className:`w-full text-left px-3 py-2 rounded-md border transition-colors ${d?"border-indigo-400 bg-indigo-50 dark:bg-indigo-900/20":"border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"font-medium text-sm text-gray-800 dark:text-gray-200 truncate",children:s.team_name??s.project_display_name}),e.jsxs("span",{className:"text-xs text-gray-500 flex items-center gap-1 flex-shrink-0",children:[e.jsx(A,{size:12}),s.agent_count]})]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5 truncate",children:["Lead ",p(s.session_id)," · ",_(s.last_ts)]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5",children:[j(s.lead_message_count)," lead msgs ·"," ",j(s.sub_agent_message_count)," sub msgs"]})]},s.session_id)})}),r&&n&&e.jsxs("div",{className:"pt-2",children:[e.jsxs("div",{className:"text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 px-1",children:["Agents (",n.agents.length+1,")"]}),e.jsxs("div",{className:"mt-2 space-y-1",role:"tree",children:[e.jsx(I,{member:n.lead,isSelected:!i,indent:0,onClick:()=>h(null)}),n.agents.map(s=>e.jsx(I,{member:s,isSelected:i===s.session_id,indent:1,onClick:()=>h(s.session_id)},s.session_id))]})]}),r&&g.isLoading&&e.jsx("div",{className:"text-xs text-gray-500 px-1",children:"Loading graph…"})]}),e.jsx("section",{className:"lg:col-span-7 xl:col-span-8",children:r?g.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load team graph:"," ",g.error instanceof Error?g.error.message:"Unknown error"]}):n?e.jsx(Q,{graph:n,member:w,onOpenTranscript:()=>{if(typeof window>"u")return;const s=new URL(window.location.href);s.searchParams.set("tab","sessions"),s.searchParams.set("session",w.session_id),window.history.pushState({},"",`${s.pathname}${s.search}`),window.dispatchEvent(new CustomEvent("stackunderflow:nav",{detail:{tab:"sessions",session:w.session_id}}))}}):e.jsx(N,{message:"Loading dependency graph..."}):e.jsx(S,{title:"Pick a team on the left",description:"Each team is a top-level Claude Code session that spawned parallel sub-agents."})})]})}function I({member:t,isSelected:a,indent:r,onClick:u}){return e.jsxs("button",{onClick:u,role:"treeitem","aria-selected":a,className:`w-full text-left flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${a?"border-indigo-400 bg-indigo-50 dark:bg-indigo-900/20":"border-transparent hover:bg-gray-100 dark:hover:bg-gray-800/50"}`,style:{paddingLeft:`${.75+r*1}rem`},"data-agent-id":t.session_id,children:[r>0&&e.jsx(T,{size:12,className:"text-gray-400 flex-shrink-0"}),t.is_lead?e.jsx(P,{size:14,className:"text-indigo-500 flex-shrink-0"}):e.jsx(A,{size:14,className:"text-gray-500 flex-shrink-0"}),e.jsxs("div",{className:"min-w-0 flex-1",children:[e.jsx("div",{className:"text-sm font-medium text-gray-800 dark:text-gray-200 truncate",children:t.is_lead?"team-lead":t.agent_name??p(t.agent_id)}),e.jsxs("div",{className:"text-xs text-gray-500 truncate",children:[j(t.message_count)," msgs · ",L(t.cost_usd)]})]})]})}function Q({graph:t,member:a,onOpenTranscript:r}){return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("header",{className:"flex items-start justify-between gap-3 border-b border-gray-200 dark:border-gray-800 pb-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"text-xs uppercase tracking-wider text-gray-500",children:[t.team_name??"Untitled team"," · ",t.project_display_name]}),e.jsx("h2",{className:"text-lg font-semibold text-gray-900 dark:text-gray-100 mt-0.5",children:a.is_lead?"Team lead":a.agent_name??p(a.agent_id)}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5",children:["session ",p(a.session_id,12),a.parent_session_id&&e.jsxs(e.Fragment,{children:[" · spawned by ",p(a.parent_session_id,12)]})]})]}),e.jsx("button",{onClick:r,className:"flex-shrink-0 text-xs px-3 py-1.5 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600",children:"Open full transcript →"})]}),e.jsxs("div",{className:"grid grid-cols-2 sm:grid-cols-4 gap-2",children:[e.jsx(f,{icon:e.jsx(q,{size:14}),label:"Messages",value:j(a.message_count)}),e.jsx(f,{icon:e.jsx(K,{size:14}),label:"Model",value:a.model?F(a.model):"—"}),e.jsx(f,{icon:e.jsx(C,{size:14}),label:"First seen",value:_(a.first_ts)}),e.jsx(f,{icon:e.jsx(C,{size:14}),label:"Last seen",value:_(a.last_ts)})]}),a.spawn_prompt&&e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"Spawn prompt"}),e.jsx("div",{className:"text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words max-h-64 overflow-y-auto",children:a.spawn_prompt})]}),e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"First user prompt"}),e.jsx("div",{className:"text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words",children:a.first_user_prompt??e.jsx("span",{className:"italic text-gray-500",children:"(no user message recorded)"})})]}),e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"Cost"}),e.jsxs("div",{className:"text-sm text-gray-800 dark:text-gray-200",children:[L(a.cost_usd)," (computed from per-message tokens)"]})]})]})}function f({icon:t,label:a,value:r}){return e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 px-3 py-2",children:[e.jsxs("div",{className:"flex items-center gap-1.5 text-xs text-gray-500",children:[t,a]}),e.jsx("div",{className:"text-sm font-medium text-gray-800 dark:text-gray-200 mt-0.5 truncate",children:r})]})}export{Z as default}; +import{r as l,u as b,j as e}from"./react-vendor-B7v2HPaI.js";import{a5 as R,a6 as z,L as N,j as T,a7 as M,a8 as U}from"./index-DQluCO2S.js";import{c as j,b as L,f as F}from"./format-Co_unrac.js";import{E as S}from"./EmptyState-o0gibvhZ.js";import{h as D}from"./dashboardTabs-C5ecR5YO.js";import{I as A}from"./IconRobot-DuCKb11z.js";import{a as P,I as q}from"./IconUser-IjRNaThB.js";import{I as K}from"./IconHash-cdeuBxnj.js";import{I as C}from"./IconClock-CgB7iTL4.js";function _(t){if(!t)return"—";try{return new Date(t).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return t}}function p(t,a=8){return t?t.length>a?`${t.slice(0,a)}…`:t:"—"}function Z({projectName:t}={}){var v;const a=R(typeof window<"u"?window.location.search:""),[r,u]=l.useState(a.session),[i,h]=l.useState(a.agent);l.useEffect(()=>{if(typeof window>"u")return;z(window.location.search,{session:r,agent:i});const s=new URL(window.location.href),d=new URLSearchParams(s.search);r?d.set("session",r):d.delete("session"),i?d.set("agent",i):d.delete("agent");const o=d.toString(),c=`${s.pathname}${o?`?${o}`:""}${s.hash}`,y=`${window.location.pathname}${window.location.search}${window.location.hash}`;c!==y&&window.history.replaceState({},"",c)},[r,i]);const x=b({queryKey:["agent-teams","list",t??"__all__"],queryFn:()=>M(50,t??null)}),g=b({queryKey:["agent-teams","graph",r],queryFn:()=>U(r),enabled:!!r}),m=((v=x.data)==null?void 0:v.teams)??[],n=g.data??null;l.useEffect(()=>{!r&&m.length>0&&u(m[0].session_id)},[m,r]);const w=l.useMemo(()=>n?i?n.agents.find(s=>s.session_id===i)??n.lead:n.lead:null,[n,i]),$=l.useRef(null),E=l.useCallback(s=>{if(!n)return;const d=[n.lead,...n.agents],o=d.findIndex(k=>i?k.session_id===i:k.is_lead);if(o<0)return;let c=o;if(s.key==="j"||s.key==="ArrowDown")c=Math.min(d.length-1,o+1);else if(s.key==="k"||s.key==="ArrowUp")c=Math.max(0,o-1);else return;s.preventDefault();const y=d[c];h(y.is_lead?null:y.session_id)},[n,i]);return x.isLoading?e.jsx(N,{message:"Loading agent teams..."}):x.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load agent teams:"," ",x.error instanceof Error?x.error.message:"Unknown error"]}):m.length===0?e.jsx(S,{icon:e.jsx(D,{size:40}),title:"No agent teams yet",description:"When Claude Code spawns parallel sub-agents (via the TeamCreate tool), the dependency graph will show up here."}):e.jsxs("div",{ref:$,onKeyDown:E,tabIndex:0,className:"grid grid-cols-1 lg:grid-cols-12 gap-4 outline-none","data-testid":"agents-tab",children:[e.jsxs("aside",{className:"lg:col-span-5 xl:col-span-4 space-y-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 px-1",children:"Recent agent teams"}),e.jsx("div",{className:"space-y-1",children:m.map(s=>{const d=s.session_id===r;return e.jsxs("button",{onClick:()=>{u(s.session_id),h(null)},className:`w-full text-left px-3 py-2 rounded-md border transition-colors ${d?"border-indigo-400 bg-indigo-50 dark:bg-indigo-900/20":"border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2",children:[e.jsx("span",{className:"font-medium text-sm text-gray-800 dark:text-gray-200 truncate",children:s.team_name??s.project_display_name}),e.jsxs("span",{className:"text-xs text-gray-500 flex items-center gap-1 flex-shrink-0",children:[e.jsx(A,{size:12}),s.agent_count]})]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5 truncate",children:["Lead ",p(s.session_id)," · ",_(s.last_ts)]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5",children:[j(s.lead_message_count)," lead msgs ·"," ",j(s.sub_agent_message_count)," sub msgs"]})]},s.session_id)})}),r&&n&&e.jsxs("div",{className:"pt-2",children:[e.jsxs("div",{className:"text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 px-1",children:["Agents (",n.agents.length+1,")"]}),e.jsxs("div",{className:"mt-2 space-y-1",role:"tree",children:[e.jsx(I,{member:n.lead,isSelected:!i,indent:0,onClick:()=>h(null)}),n.agents.map(s=>e.jsx(I,{member:s,isSelected:i===s.session_id,indent:1,onClick:()=>h(s.session_id)},s.session_id))]})]}),r&&g.isLoading&&e.jsx("div",{className:"text-xs text-gray-500 px-1",children:"Loading graph…"})]}),e.jsx("section",{className:"lg:col-span-7 xl:col-span-8",children:r?g.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load team graph:"," ",g.error instanceof Error?g.error.message:"Unknown error"]}):n?e.jsx(Q,{graph:n,member:w,onOpenTranscript:()=>{if(typeof window>"u")return;const s=new URL(window.location.href);s.searchParams.set("tab","sessions"),s.searchParams.set("session",w.session_id),window.history.pushState({},"",`${s.pathname}${s.search}`),window.dispatchEvent(new CustomEvent("stackunderflow:nav",{detail:{tab:"sessions",session:w.session_id}}))}}):e.jsx(N,{message:"Loading dependency graph..."}):e.jsx(S,{title:"Pick a team on the left",description:"Each team is a top-level Claude Code session that spawned parallel sub-agents."})})]})}function I({member:t,isSelected:a,indent:r,onClick:u}){return e.jsxs("button",{onClick:u,role:"treeitem","aria-selected":a,className:`w-full text-left flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${a?"border-indigo-400 bg-indigo-50 dark:bg-indigo-900/20":"border-transparent hover:bg-gray-100 dark:hover:bg-gray-800/50"}`,style:{paddingLeft:`${.75+r*1}rem`},"data-agent-id":t.session_id,children:[r>0&&e.jsx(T,{size:12,className:"text-gray-400 flex-shrink-0"}),t.is_lead?e.jsx(P,{size:14,className:"text-indigo-500 flex-shrink-0"}):e.jsx(A,{size:14,className:"text-gray-500 flex-shrink-0"}),e.jsxs("div",{className:"min-w-0 flex-1",children:[e.jsx("div",{className:"text-sm font-medium text-gray-800 dark:text-gray-200 truncate",children:t.is_lead?"team-lead":t.agent_name??p(t.agent_id)}),e.jsxs("div",{className:"text-xs text-gray-500 truncate",children:[j(t.message_count)," msgs · ",L(t.cost_usd)]})]})]})}function Q({graph:t,member:a,onOpenTranscript:r}){return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("header",{className:"flex items-start justify-between gap-3 border-b border-gray-200 dark:border-gray-800 pb-3",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"text-xs uppercase tracking-wider text-gray-500",children:[t.team_name??"Untitled team"," · ",t.project_display_name]}),e.jsx("h2",{className:"text-lg font-semibold text-gray-900 dark:text-gray-100 mt-0.5",children:a.is_lead?"Team lead":a.agent_name??p(a.agent_id)}),e.jsxs("div",{className:"text-xs text-gray-500 mt-0.5",children:["session ",p(a.session_id,12),a.parent_session_id&&e.jsxs(e.Fragment,{children:[" · spawned by ",p(a.parent_session_id,12)]})]})]}),e.jsx("button",{onClick:r,className:"flex-shrink-0 text-xs px-3 py-1.5 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600",children:"Open full transcript →"})]}),e.jsxs("div",{className:"grid grid-cols-2 sm:grid-cols-4 gap-2",children:[e.jsx(f,{icon:e.jsx(q,{size:14}),label:"Messages",value:j(a.message_count)}),e.jsx(f,{icon:e.jsx(K,{size:14}),label:"Model",value:a.model?F(a.model):"—"}),e.jsx(f,{icon:e.jsx(C,{size:14}),label:"First seen",value:_(a.first_ts)}),e.jsx(f,{icon:e.jsx(C,{size:14}),label:"Last seen",value:_(a.last_ts)})]}),a.spawn_prompt&&e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"Spawn prompt"}),e.jsx("div",{className:"text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words max-h-64 overflow-y-auto",children:a.spawn_prompt})]}),e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"First user prompt"}),e.jsx("div",{className:"text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words",children:a.first_user_prompt??e.jsx("span",{className:"italic text-gray-500",children:"(no user message recorded)"})})]}),e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 p-3",children:[e.jsx("div",{className:"text-xs uppercase tracking-wider text-gray-500 mb-1",children:"Cost"}),e.jsxs("div",{className:"text-sm text-gray-800 dark:text-gray-200",children:[L(a.cost_usd)," (computed from per-message tokens)"]})]})]})}function f({icon:t,label:a,value:r}){return e.jsxs("div",{className:"rounded-md border border-gray-200 dark:border-gray-800 px-3 py-2",children:[e.jsxs("div",{className:"flex items-center gap-1.5 text-xs text-gray-500",children:[t,a]}),e.jsx("div",{className:"text-sm font-medium text-gray-800 dark:text-gray-200 mt-0.5 truncate",children:r})]})}export{Z as default}; diff --git a/stackunderflow/static/react/assets/BookmarksTab-DAjJ72Pj.js b/stackunderflow/static/react/assets/BookmarksTab-Cor0cmyo.js similarity index 96% rename from stackunderflow/static/react/assets/BookmarksTab-DAjJ72Pj.js rename to stackunderflow/static/react/assets/BookmarksTab-Cor0cmyo.js index 28094ca..1fb2703 100644 --- a/stackunderflow/static/react/assets/BookmarksTab-DAjJ72Pj.js +++ b/stackunderflow/static/react/assets/BookmarksTab-Cor0cmyo.js @@ -1,4 +1,4 @@ -import{j as e,d as v,r as g,u as j,e as y}from"./react-vendor-B7v2HPaI.js";import{h as N,Z as C,_ as w,L as S,z as B,$ as I}from"./index-DDMGE_ZF.js";import{E}from"./EmptyState-o0gibvhZ.js";import{I as k,a as T}from"./FilterBar-DuduzIn6.js";import{M as z}from"./Modal-Da9xsK-A.js";import{T as M}from"./TimeAgo-BNE6wf6R.js";import{I as q}from"./IconSortDescending-nc0XizMI.js";import{f as D}from"./dashboardTabs-CJWioGLA.js";import{I as _}from"./IconCheck-DwMBTi_m.js";import"./format-Co_unrac.js";/** +import{j as e,d as v,r as g,u as j,e as y}from"./react-vendor-B7v2HPaI.js";import{h as N,Z as C,_ as w,L as S,z as B,$ as I}from"./index-DQluCO2S.js";import{E}from"./EmptyState-o0gibvhZ.js";import{I as k,a as T}from"./FilterBar-BS6lhcC1.js";import{M as z}from"./Modal-DkeyqO4J.js";import{T as M}from"./TimeAgo-BNE6wf6R.js";import{I as q}from"./IconSortDescending-DunfClYo.js";import{f as D}from"./dashboardTabs-C5ecR5YO.js";import{I as _}from"./IconCheck-BQLu3HFV.js";import"./format-Co_unrac.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/BudgetsTab-n3gfgUaU.js b/stackunderflow/static/react/assets/BudgetsTab-BjHaaUsj.js similarity index 98% rename from stackunderflow/static/react/assets/BudgetsTab-n3gfgUaU.js rename to stackunderflow/static/react/assets/BudgetsTab-BjHaaUsj.js index 8226850..539a522 100644 --- a/stackunderflow/static/react/assets/BudgetsTab-n3gfgUaU.js +++ b/stackunderflow/static/react/assets/BudgetsTab-BjHaaUsj.js @@ -1,4 +1,4 @@ -import{r as g,j as e,d as S,u as N,e as v}from"./react-vendor-B7v2HPaI.js";import{h as B,p as M,G as z,z as D,B as E,u as I,ag as R,ah as L,L as w,ai as T,aj as $}from"./index-DDMGE_ZF.js";import{E as Q}from"./EmptyState-o0gibvhZ.js";import{b as c,a as q,f as K}from"./format-Co_unrac.js";import{k as _}from"./dashboardTabs-CJWioGLA.js";import{I as P}from"./IconCheck-DwMBTi_m.js";import{R as U,B as W,C as G,X as V,Y as X,T as Y,j as H,b as J,d as O}from"./recharts-C8DDeE7E.js";/** +import{r as g,j as e,d as S,u as N,e as v}from"./react-vendor-B7v2HPaI.js";import{h as B,p as M,G as z,z as D,B as E,u as I,ag as R,ah as L,L as w,ai as T,aj as $}from"./index-DQluCO2S.js";import{E as Q}from"./EmptyState-o0gibvhZ.js";import{b as c,a as q,f as K}from"./format-Co_unrac.js";import{k as _}from"./dashboardTabs-C5ecR5YO.js";import{I as P}from"./IconCheck-BQLu3HFV.js";import{R as U,B as W,C as G,X as V,Y as X,T as Y,j as H,b as J,d as O}from"./recharts-C8DDeE7E.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/CodingHealthTab-B02yOcgC.js b/stackunderflow/static/react/assets/CodingHealthTab-B02yOcgC.js new file mode 100644 index 0000000..9f37a46 --- /dev/null +++ b/stackunderflow/static/react/assets/CodingHealthTab-B02yOcgC.js @@ -0,0 +1,7 @@ +import{r as g,u as S,j as e,e as C}from"./react-vendor-B7v2HPaI.js";import{h as z,L,p as b,I as h,B as m,G as T}from"./index-DQluCO2S.js";import{E as I}from"./EmptyState-o0gibvhZ.js";import{e as w,j as F}from"./dashboardTabs-C5ecR5YO.js";import{I as k}from"./IconFileText-ChD_eRqq.js";import{I as A}from"./IconBolt-CWu2Wz0a.js";import{I as R}from"./FilterBar-BS6lhcC1.js";import"./format-Co_unrac.js";/** + * @license @tabler/icons-react v3.36.1 - MIT + * + * This source code is licensed under the MIT license. + * See the LICENSE file in the root directory of this source tree. + */const M=[["path",{d:"M5 7l5 5l-5 5",key:"svg-0"}],["path",{d:"M12 19l7 0",key:"svg-1"}]],N=z("outline","terminal","Terminal",M),_="/api";async function v(t,r){const s=await fetch(t,r);if(!s.ok){const n=await s.text().catch(()=>"");throw new Error(`${s.status} ${s.statusText}${n?`: ${n}`:""}`)}return s.json()}async function E(t="90d",r){const s=new URLSearchParams({since:t});return v(`${_}/patterns?${s}`)}async function O(t){return v(`${_}/patterns/dismiss`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}const P=[{id:"7d",label:"7d"},{id:"30d",label:"30d"},{id:"90d",label:"90d"}];function $(t){return t===null||!Number.isFinite(t)?"—":`${(t*100).toFixed(0)}%`}function y(t){return t?t.slice(0,10):"—"}function f(t){const r=t.replace(/\\/g,"/").split("/");return r[r.length-1]||t}function j(t){let r=null,s=0;for(const[n,o]of Object.entries(t))(o>s||o===s&&r!==null&&n=.5?"red":t>=.2?"yellow":"gray"}function u({icon:t,title:r,value:s,sub:n,color:o}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[e.jsx("span",{className:o,children:t}),e.jsx("span",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:r})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 tabular-nums",children:s}),n&&e.jsx("div",{className:"text-xs text-gray-500 mt-1 tabular-nums",children:n})]})}function H({file:t}){const r=j(t.categories);return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 max-w-[16rem]",children:e.jsx("span",{title:`${t.path} +${t.reason}`,className:"block truncate",children:f(t.path)})}),e.jsx("td",{className:"px-3 py-2",children:e.jsx(m,{color:B(t.failure_rate),size:"sm",children:$(t.failure_rate)})}),e.jsxs("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:[t.failure_session_count," / ",t.touch_session_count]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.touch_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap",children:r?e.jsx(m,{color:"gray",size:"sm",children:r}):"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:y(t.last_failure_ts)})]})}function K({sig:t}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-3 space-y-2",children:[e.jsxs("div",{className:"flex items-start justify-between gap-2 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[e.jsx(m,{color:"red",size:"sm",children:t.category}),e.jsx("span",{className:"text-xs font-mono text-gray-800 dark:text-gray-200 truncate",title:t.example||t.signature,children:t.signature})]}),e.jsxs("div",{className:"flex items-center gap-2 text-[11px] text-gray-500 whitespace-nowrap tabular-nums",children:[e.jsxs("span",{children:[t.session_count," sessions"]}),e.jsx("span",{children:"·"}),e.jsxs("span",{children:[t.count,"×"]}),e.jsx("span",{children:"·"}),e.jsxs("span",{children:["last ",y(t.last_ts)]})]})]}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap text-[11px] text-gray-500",children:[t.resolved_session_count>0?e.jsxs("span",{className:"inline-flex items-center gap-1 text-green-700 dark:text-green-400",children:[e.jsx(T,{size:12}),t.resolved_session_count," moved past it"]}):e.jsxs("span",{className:"inline-flex items-center gap-1 text-gray-500",children:[e.jsx(h,{size:12}),"no session in window moved past it"]}),t.resolution_hints.map(r=>e.jsxs(m,{color:"green",size:"sm",children:["next: ",r.action," ×",r.count]},r.action)),t.top_files.slice(0,2).map(r=>e.jsx("span",{className:"font-mono",title:r,children:f(r)},r))]})]})}function D({cluster:t}){const r=j(t.categories);return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap",children:e.jsx("span",{title:t.example,children:t.command})}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.failure_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.session_count}),e.jsx("td",{className:"px-3 py-2 whitespace-nowrap",children:r?e.jsx(m,{color:"orange",size:"sm",children:r}):"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:y(t.last_failure_ts)})]})}const q=8;function W(t){const r=[];for(const s of t.error_signatures){if(s.session_count<2)continue;const n=s.resolution_hints[0];n&&r.push({key:`error-signature:${s.category}:${s.signature}`,type:"error-signature",targetKey:s.signature,counts:[s.session_count,s.count],typeLabel:"error",badgeColor:"red",icon:e.jsx(h,{size:14}),text:`Recurring "${s.signature}" (${s.session_count} sessions). Next step that worked: ${n.action}.`})}for(const s of t.command_clusters){if(s.failure_count<2||s.session_count<2)continue;const n=j(s.categories);r.push({key:`command-cluster:${s.command}`,type:"command-cluster",targetKey:s.command,counts:[s.failure_count,s.session_count],typeLabel:"command",badgeColor:"orange",icon:e.jsx(N,{size:14}),text:`\`${s.command}\` failed in ${s.session_count} recent sessions${n?` — mostly ${n}`:""}.`})}for(const s of t.file_risk)s.failure_session_count<1||r.push({key:`file-risk:${s.path}`,type:"file-risk",targetKey:s.path,counts:[s.failure_count,s.failure_session_count],typeLabel:"file",badgeColor:"yellow",icon:e.jsx(k,{size:14}),text:`${f(s.path)} has failure history (${s.failure_session_count}/${s.touch_session_count} sessions${s.failure_rate!==null?`, ${$(s.failure_rate)}`:""}).`});return r.slice(0,q)}function J({nudges:t}){const[r,s]=g.useState(()=>new Set),[n,o]=g.useState(()=>new Set),c=C({mutationFn:i=>O(i)});if(t.length===0)return null;const a=t.filter(i=>!r.has(i.key)&&!n.has(i.type)),d=i=>{s(x=>new Set(x).add(i.key)),c.mutate({type:i.type,scope:"fingerprint",target_key:i.targetKey,counts:i.counts})},p=i=>{o(x=>new Set(x).add(i.type)),c.mutate({type:i.type,scope:"type"})};return e.jsxs("div",{className:"space-y-2",children:[e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(A,{size:14,className:"text-amber-500"}),e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"What almost bit me"}),e.jsx("span",{className:"text-[11px] text-gray-400",children:"nudges that would fire in-session — dismiss to quiet them"})]}),a.length===0?e.jsx("div",{className:"px-1 text-xs italic text-gray-500",children:"All caught up — nothing to review."}):e.jsx("div",{className:"space-y-2",children:a.map(i=>e.jsxs("div",{className:"flex items-start justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-3",children:[e.jsxs("div",{className:"flex min-w-0 items-start gap-2",children:[e.jsx("span",{className:"mt-0.5 text-gray-400",children:i.icon}),e.jsxs("div",{className:"min-w-0",children:[e.jsx(m,{color:i.badgeColor,size:"sm",children:i.typeLabel}),e.jsx("p",{className:"mt-1 break-words text-xs text-gray-700 dark:text-gray-300",children:i.text})]})]}),e.jsxs("div",{className:"flex flex-shrink-0 items-center gap-1",children:[e.jsxs("button",{type:"button",onClick:()=>d(i),disabled:c.isPending,className:"inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-gray-200",title:"Quiet this specific nudge in future sessions",children:[e.jsx(R,{size:12}),"Dismiss"]}),e.jsx("button",{type:"button",onClick:()=>p(i),disabled:c.isPending,className:"whitespace-nowrap rounded-md px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-gray-200",title:`Stop showing ${i.typeLabel} nudges`,children:"Don't show again"})]})]},i.key))}),c.isError&&e.jsx("div",{className:"px-1 text-[11px] text-red-600 dark:text-red-400",children:"Couldn't record that dismissal — it may reappear next time."})]})}function te({projectName:t}){const[r,s]=g.useState("90d"),{data:n,isLoading:o,error:c}=S({queryKey:["patterns",t,r],queryFn:()=>E(r),staleTime:6e4}),a=n==null?void 0:n.report,d=a==null?void 0:a.totals,p=((d==null?void 0:d.session_count)??0)>0,i=!!a&&(a.file_risk.length>0||a.error_signatures.length>0||a.command_clusters.length>0),x=g.useMemo(()=>a?W(a):[],[a]);return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(w,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Coding Health"}),e.jsx("span",{className:"text-xs text-gray-500",children:"recurring failures across sessions — files, errors, commands"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Coding health window",children:P.map(l=>e.jsx("button",{type:"button",onClick:()=>s(l.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${l.id===r?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:l.label},l.id))})]}),o&&e.jsx(L,{message:"Mining cross-session patterns..."}),c&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load coding health: ",c instanceof Error?c.message:"Unknown error"]}),!o&&!c&&a&&!a.sources.message_tool_mart&&e.jsxs("div",{className:"flex items-start gap-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-md p-3 text-yellow-800 dark:text-yellow-300 text-xs",children:[e.jsx(b,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsxs("span",{children:["File-touch history is unavailable (the per-tool mart is empty), so failure rates cannot be computed. Run ",e.jsx("code",{className:"font-mono",children:"stackunderflow etl backfill"})," ","to materialize it."]})]}),!o&&!c&&d&&e.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-4 gap-3",children:[e.jsx(u,{icon:e.jsx(b,{size:16}),title:"Sessions with failures",value:d.sessions_with_failures.toLocaleString(),sub:`of ${d.session_count.toLocaleString()} sessions in window`,color:"text-red-500"}),e.jsx(u,{icon:e.jsx(k,{size:16}),title:"Files at risk",value:((a==null?void 0:a.file_risk.length)??0).toLocaleString(),sub:`${d.files_touched.toLocaleString()} files touched`,color:"text-yellow-500"}),e.jsx(u,{icon:e.jsx(h,{size:16}),title:"Tool errors",value:d.error_count.toLocaleString(),sub:`${d.attributed_error_count.toLocaleString()} attributed to a call`,color:"text-orange-500"}),e.jsx(u,{icon:e.jsx(F,{size:16}),title:"Interruptions",value:d.interruption_count.toLocaleString(),sub:`across ${d.interruption_session_count.toLocaleString()} sessions`,color:"text-purple-500"})]}),!o&&!c&&x.length>0&&e.jsx(J,{nudges:x}),!o&&!c&&a&&!i&&e.jsx(I,{icon:e.jsx(w,{size:28}),title:p?"No recurring failure patterns in window":"No activity in window",description:p?"Nothing failed repeatedly across sessions in this period. Healthy — or try a wider window.":"No tool calls or errors were recorded in this period. Ingest sessions or widen the window."}),!o&&!c&&a&&a.file_risk.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"File risk — fails when touched"}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"File"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failure rate"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failing / touching sessions"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Touches"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Top error"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Last failure"})]})}),e.jsx("tbody",{children:a.file_risk.map(l=>e.jsx(H,{file:l},l.path))})]})})]}),!o&&!c&&a&&a.error_signatures.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Recurring errors — same signature, multiple sessions"}),e.jsx("div",{className:"space-y-2",children:a.error_signatures.map(l=>e.jsx(K,{sig:l},`${l.category}:${l.signature}`))})]}),!o&&!c&&a&&a.command_clusters.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(N,{size:12}),"Command failure clusters"]})}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Command"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failures"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Sessions"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Top error"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Last failure"})]})}),e.jsx("tbody",{children:a.command_clusters.map(l=>e.jsx(D,{cluster:l},l.command))})]})})]})]})}export{te as default}; diff --git a/stackunderflow/static/react/assets/CodingHealthTab-Cz4e1hEF.js b/stackunderflow/static/react/assets/CodingHealthTab-Cz4e1hEF.js deleted file mode 100644 index ad6c14e..0000000 --- a/stackunderflow/static/react/assets/CodingHealthTab-Cz4e1hEF.js +++ /dev/null @@ -1,7 +0,0 @@ -import{r as b,u as w,j as e}from"./react-vendor-B7v2HPaI.js";import{h as N,L as k,p as g,I as h,B as d,G as _}from"./index-DDMGE_ZF.js";import{E as v}from"./EmptyState-o0gibvhZ.js";import{e as u,j as S}from"./dashboardTabs-CJWioGLA.js";import{I as z}from"./IconFileText-C9y_jNvO.js";/** - * @license @tabler/icons-react v3.36.1 - MIT - * - * This source code is licensed under the MIT license. - * See the LICENSE file in the root directory of this source tree. - */const $=[["path",{d:"M5 7l5 5l-5 5",key:"svg-0"}],["path",{d:"M12 19l7 0",key:"svg-1"}]],C=N("outline","terminal","Terminal",$),F="/api";async function L(t,s){const a=await fetch(t,s);if(!a.ok){const o=await a.text().catch(()=>"");throw new Error(`${a.status} ${a.statusText}${o?`: ${o}`:""}`)}return a.json()}async function I(t="90d",s){const a=new URLSearchParams({since:t});return L(`${F}/patterns?${a}`)}const T=[{id:"7d",label:"7d"},{id:"30d",label:"30d"},{id:"90d",label:"90d"}];function R(t){return t===null||!Number.isFinite(t)?"—":`${(t*100).toFixed(0)}%`}function p(t){return t?t.slice(0,10):"—"}function y(t){const s=t.replace(/\\/g,"/").split("/");return s[s.length-1]||t}function j(t){let s=null,a=0;for(const[o,i]of Object.entries(t))(i>a||i===a&&s!==null&&o=.5?"red":t>=.2?"yellow":"gray"}function x({icon:t,title:s,value:a,sub:o,color:i}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[e.jsx("span",{className:i,children:t}),e.jsx("span",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:s})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 tabular-nums",children:a}),o&&e.jsx("div",{className:"text-xs text-gray-500 mt-1 tabular-nums",children:o})]})}function A({file:t}){const s=j(t.categories);return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 max-w-[16rem]",children:e.jsx("span",{title:`${t.path} -${t.reason}`,className:"block truncate",children:y(t.path)})}),e.jsx("td",{className:"px-3 py-2",children:e.jsx(d,{color:E(t.failure_rate),size:"sm",children:R(t.failure_rate)})}),e.jsxs("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:[t.failure_session_count," / ",t.touch_session_count]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.touch_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap",children:s?e.jsx(d,{color:"gray",size:"sm",children:s}):"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:p(t.last_failure_ts)})]})}function H({sig:t}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-3 space-y-2",children:[e.jsxs("div",{className:"flex items-start justify-between gap-2 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[e.jsx(d,{color:"red",size:"sm",children:t.category}),e.jsx("span",{className:"text-xs font-mono text-gray-800 dark:text-gray-200 truncate",title:t.example||t.signature,children:t.signature})]}),e.jsxs("div",{className:"flex items-center gap-2 text-[11px] text-gray-500 whitespace-nowrap tabular-nums",children:[e.jsxs("span",{children:[t.session_count," sessions"]}),e.jsx("span",{children:"·"}),e.jsxs("span",{children:[t.count,"×"]}),e.jsx("span",{children:"·"}),e.jsxs("span",{children:["last ",p(t.last_ts)]})]})]}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap text-[11px] text-gray-500",children:[t.resolved_session_count>0?e.jsxs("span",{className:"inline-flex items-center gap-1 text-green-700 dark:text-green-400",children:[e.jsx(_,{size:12}),t.resolved_session_count," moved past it"]}):e.jsxs("span",{className:"inline-flex items-center gap-1 text-gray-500",children:[e.jsx(h,{size:12}),"no session in window moved past it"]}),t.resolution_hints.map(s=>e.jsxs(d,{color:"green",size:"sm",children:["next: ",s.action," ×",s.count]},s.action)),t.top_files.slice(0,2).map(s=>e.jsx("span",{className:"font-mono",title:s,children:y(s)},s))]})]})}function B({cluster:t}){const s=j(t.categories);return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap",children:e.jsx("span",{title:t.example,children:t.command})}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.failure_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.session_count}),e.jsx("td",{className:"px-3 py-2 whitespace-nowrap",children:s?e.jsx(d,{color:"orange",size:"sm",children:s}):"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:p(t.last_failure_ts)})]})}function W({projectName:t}){const[s,a]=b.useState("90d"),{data:o,isLoading:i,error:c}=w({queryKey:["patterns",t,s],queryFn:()=>I(s),staleTime:6e4}),r=o==null?void 0:o.report,l=r==null?void 0:r.totals,m=((l==null?void 0:l.session_count)??0)>0,f=!!r&&(r.file_risk.length>0||r.error_signatures.length>0||r.command_clusters.length>0);return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(u,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Coding Health"}),e.jsx("span",{className:"text-xs text-gray-500",children:"recurring failures across sessions — files, errors, commands"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Coding health window",children:T.map(n=>e.jsx("button",{type:"button",onClick:()=>a(n.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${n.id===s?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:n.label},n.id))})]}),i&&e.jsx(k,{message:"Mining cross-session patterns..."}),c&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load coding health: ",c instanceof Error?c.message:"Unknown error"]}),!i&&!c&&r&&!r.sources.message_tool_mart&&e.jsxs("div",{className:"flex items-start gap-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-md p-3 text-yellow-800 dark:text-yellow-300 text-xs",children:[e.jsx(g,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsxs("span",{children:["File-touch history is unavailable (the per-tool mart is empty), so failure rates cannot be computed. Run ",e.jsx("code",{className:"font-mono",children:"stackunderflow etl backfill"})," ","to materialize it."]})]}),!i&&!c&&l&&e.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-4 gap-3",children:[e.jsx(x,{icon:e.jsx(g,{size:16}),title:"Sessions with failures",value:l.sessions_with_failures.toLocaleString(),sub:`of ${l.session_count.toLocaleString()} sessions in window`,color:"text-red-500"}),e.jsx(x,{icon:e.jsx(z,{size:16}),title:"Files at risk",value:((r==null?void 0:r.file_risk.length)??0).toLocaleString(),sub:`${l.files_touched.toLocaleString()} files touched`,color:"text-yellow-500"}),e.jsx(x,{icon:e.jsx(h,{size:16}),title:"Tool errors",value:l.error_count.toLocaleString(),sub:`${l.attributed_error_count.toLocaleString()} attributed to a call`,color:"text-orange-500"}),e.jsx(x,{icon:e.jsx(S,{size:16}),title:"Interruptions",value:l.interruption_count.toLocaleString(),sub:`across ${l.interruption_session_count.toLocaleString()} sessions`,color:"text-purple-500"})]}),!i&&!c&&r&&!f&&e.jsx(v,{icon:e.jsx(u,{size:28}),title:m?"No recurring failure patterns in window":"No activity in window",description:m?"Nothing failed repeatedly across sessions in this period. Healthy — or try a wider window.":"No tool calls or errors were recorded in this period. Ingest sessions or widen the window."}),!i&&!c&&r&&r.file_risk.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"File risk — fails when touched"}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"File"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failure rate"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failing / touching sessions"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Touches"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Top error"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Last failure"})]})}),e.jsx("tbody",{children:r.file_risk.map(n=>e.jsx(A,{file:n},n.path))})]})})]}),!i&&!c&&r&&r.error_signatures.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Recurring errors — same signature, multiple sessions"}),e.jsx("div",{className:"space-y-2",children:r.error_signatures.map(n=>e.jsx(H,{sig:n},`${n.category}:${n.signature}`))})]}),!i&&!c&&r&&r.command_clusters.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:e.jsxs("span",{className:"inline-flex items-center gap-1",children:[e.jsx(C,{size:12}),"Command failure clusters"]})}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Command"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Failures"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Sessions"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Top error"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Last failure"})]})}),e.jsx("tbody",{children:r.command_clusters.map(n=>e.jsx(B,{cluster:n},n.command))})]})})]})]})}export{W as default}; diff --git a/stackunderflow/static/react/assets/CommandsTab-D4oiPDTW.js b/stackunderflow/static/react/assets/CommandsTab-DlkmMFl4.js similarity index 93% rename from stackunderflow/static/react/assets/CommandsTab-D4oiPDTW.js rename to stackunderflow/static/react/assets/CommandsTab-DlkmMFl4.js index 34e69ad..941e2d7 100644 --- a/stackunderflow/static/react/assets/CommandsTab-D4oiPDTW.js +++ b/stackunderflow/static/react/assets/CommandsTab-DlkmMFl4.js @@ -1,3 +1,3 @@ -import{r as h,j as t}from"./react-vendor-B7v2HPaI.js";import{D as y}from"./DataTable-D0JcWuTA.js";import{i as f,j,B as k}from"./index-DDMGE_ZF.js";import{f as u}from"./format-Co_unrac.js";import"./ProjectDashboard-DRHQbkve.js";import"./EmptyState-o0gibvhZ.js";import"./FilterBar-DuduzIn6.js";import"./dashboardTabs-CJWioGLA.js";import"./IconArrowUp-CszCUTPq.js";function b(a){const r=[];let c=1;for(let d=0;db(d),[d]),o=h.useMemo(()=>[{key:"index",label:"#",width:"50px",align:"right",render:e=>t.jsx("span",{className:"text-gray-500 text-xs",children:e.index}),sortValue:e=>e.index},{key:"command",label:"Command",render:e=>t.jsxs("div",{className:"flex items-start gap-1.5 min-w-0",children:[r===e.groupKey?t.jsx(f,{size:14,className:"text-gray-500 mt-0.5 shrink-0"}):t.jsx(j,{size:14,className:"text-gray-500 mt-0.5 shrink-0"}),t.jsx("span",{className:"text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words min-w-0",children:e.command.content.length>400?e.command.content.slice(0,400)+"…":e.command.content})]})},{key:"timestamp",label:"Timestamp",width:"140px",render:e=>t.jsx("span",{className:"text-gray-600 dark:text-gray-400 text-xs whitespace-nowrap",children:N(e.timestamp)}),sortValue:e=>new Date(e.timestamp).getTime()},{key:"model",label:"Model",width:"160px",render:e=>e.model?t.jsx("span",{className:"text-gray-600 dark:text-gray-400 text-xs",title:e.model,children:u(e.model)}):t.jsx("span",{className:"text-gray-500 text-xs",children:"-"})},{key:"tools",label:"Tools Used",width:"200px",render:e=>e.toolsUsed.length>0?t.jsx("div",{className:"flex flex-wrap gap-1",children:e.toolsUsed.map(s=>t.jsx(k,{color:"purple",size:"sm",children:s},s))}):t.jsx("span",{className:"text-gray-500 text-xs",children:"-"}),sortValue:e=>e.toolsUsed.length},{key:"tokens",label:"Tokens",width:"80px",align:"right",render:e=>{const s=e.command.tokens;if(s){const l=s.input+s.output;return t.jsx("span",{className:"text-gray-600 dark:text-gray-400 text-xs",title:`In: ${s.input} / Out: ${s.output}`,children:l.toLocaleString()})}return t.jsx("span",{className:"text-gray-500 text-xs",children:"-"})}}],[r]),x=e=>{c(s=>s===e.groupKey?null:e.groupKey)},g=e=>{const l=[["#","Command","Timestamp","Model","Tools Used"].join(",")];for(const i of e)l.push([String(i.index),p(i.command.content),p(i.timestamp),p(i.model??""),p(i.toolsUsed.join("; "))].join(","));return l.join(` `)};return t.jsxs("div",{className:"space-y-2",children:[t.jsx("div",{className:"flex items-center justify-between",children:t.jsxs("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:["User Commands",t.jsxs("span",{className:"ml-2 text-xs text-gray-500 font-normal",children:[n.length," command",n.length!==1?"s":""]})]})}),t.jsx(y,{columns:o,data:n,keyFn:e=>e.groupKey,searchable:!0,searchPlaceholder:"Filter commands...",searchFn:(e,s)=>{var l;return e.command.content.toLowerCase().includes(s)||(((l=e.model)==null?void 0:l.toLowerCase().includes(s))??!1)||e.toolsUsed.some(i=>i.toLowerCase().includes(s))},onRowClick:x,perPageOptions:[25,50,100,200],defaultPerPage:25,exportFilename:"commands.csv",exportFn:g,emptyMessage:"No user commands found"}),r&&(()=>{const e=n.find(s=>s.groupKey===r);return e?t.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4 text-sm",children:[t.jsxs("div",{className:"flex items-center justify-between mb-2",children:[t.jsx("span",{className:"text-xs text-gray-600 dark:text-gray-400 font-medium uppercase tracking-wider",children:"Full Command Text"}),t.jsx("button",{onClick:()=>c(null),className:"text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300",children:"Close"})]}),t.jsx("pre",{className:"text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed max-h-96 overflow-y-auto",children:e.command.content}),t.jsx("div",{className:"mt-3 pt-3 border-t border-gray-200 dark:border-gray-700",children:t.jsxs("span",{className:"text-xs text-gray-500",children:["Session: ",e.command.session_id,e.model&&t.jsxs(t.Fragment,{children:[" | Model: ",t.jsx("span",{title:e.model,children:u(e.model)})]}),e.toolsUsed.length>0&&t.jsxs(t.Fragment,{children:[" | Tools: ",e.toolsUsed.join(", ")]})]})})]}):null})()]})}export{K as default}; diff --git a/stackunderflow/static/react/assets/CompareTab-CVqkwtRX.js b/stackunderflow/static/react/assets/CompareTab-D60fXyc4.js similarity index 98% rename from stackunderflow/static/react/assets/CompareTab-CVqkwtRX.js rename to stackunderflow/static/react/assets/CompareTab-D60fXyc4.js index a375fea..470eeb8 100644 --- a/stackunderflow/static/react/assets/CompareTab-CVqkwtRX.js +++ b/stackunderflow/static/react/assets/CompareTab-D60fXyc4.js @@ -1 +1 @@ -import{r as p,u as w,j as e}from"./react-vendor-B7v2HPaI.js";import{u as C,L as S,ak as P,a as E,al as F}from"./index-DDMGE_ZF.js";import{E as $}from"./EmptyState-o0gibvhZ.js";import{P as z}from"./ProviderChip-PiRoWNpI.js";import{b as y,f as R,c as N}from"./format-Co_unrac.js";import{l as m}from"./dashboardTabs-CJWioGLA.js";import{I as A}from"./IconAlertCircle-DyBnJSK0.js";const I=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function h(t){return t==null||!Number.isFinite(t)?"—":`${(t*100).toFixed(0)}%`}const v={high:"bg-green-500/10 text-green-600 dark:text-green-400",medium:"bg-blue-500/10 text-blue-600 dark:text-blue-400",low:"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",none:"bg-gray-500/10 text-gray-500 dark:text-gray-400"},T={clear:"bg-green-500/10 text-green-600 dark:text-green-400",weak:"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400","insufficient evidence":"bg-gray-500/10 text-gray-500 dark:text-gray-400"};function k({label:t,className:r}){return e.jsx("span",{className:`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${r}`,children:t})}function q({report:t}){const r=t.coverage;return e.jsxs("div",{className:"flex items-start gap-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3 text-blue-800 dark:text-blue-300 text-xs",children:[e.jsx(A,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsxs("span",{children:["Based on ",r.sessions_total.toLocaleString()," sessions you already ran — a natural experiment, not a controlled trial. Success measured on"," ",r.sessions_scored.toLocaleString(),"/",r.sessions_total.toLocaleString()," ","sessions · grade coverage ",h(r.grade_coverage),". Weights: success"," ",t.weights.success,", cost ",t.weights.cost,", effort"," ",t.weights.effort," · ",Math.round(t.ci_level*100),"% CI."]})]})}function B({report:t,currency:r}){const o=t.verdict;return o.winning_model?e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(m,{size:16,className:"text-yellow-500"}),e.jsx("span",{className:"text-sm font-semibold text-gray-900 dark:text-gray-100",children:o.headline}),e.jsx(k,{label:o.confidence,className:v[o.confidence]})]}),e.jsxs("div",{className:"text-xs text-gray-600 dark:text-gray-400 mt-2 tabular-nums",children:[o.cost_per_outcome_usd!==null&&e.jsxs("span",{children:[y(o.cost_per_outcome_usd,r)," / successful outcome"]}),o.runner_up&&e.jsxs("span",{children:[" · runner-up ",o.runner_up]})]})]}):e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(k,{label:"insufficient evidence",className:v.none}),e.jsx("span",{className:"text-sm text-gray-600 dark:text-gray-400",children:"No cross-task winner yet"})]}),o.caveats[0]&&e.jsx("p",{className:"text-xs text-gray-500 mt-2",children:o.caveats[0]})]})}function O({row:t,isWinner:r,currency:o}){const s=t.success_rate.ci_wilson,a=t.cost_per_outcome.point,l=t.qualified?"":"opacity-50";return e.jsxs("tr",{className:`border-t border-gray-200 dark:border-gray-800 ${l}`,children:[e.jsxs("td",{className:"px-3 py-2 text-xs font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap",children:[r&&e.jsx(m,{size:12,className:"inline mr-1 text-yellow-500"}),t.model,!t.qualified&&e.jsx("span",{className:"ml-2 text-[10px] text-gray-400",children:"insufficient evidence"})]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums",children:t.n}),e.jsxs("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:[h(t.success_rate.point),s&&e.jsxs("span",{className:"text-gray-400",children:[" ","[",h(s[0]),"–",h(s[1]),"]"]})]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:a!==null?`${y(a,o)}/outcome`:"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums",children:h(t.coverage)}),e.jsx("td",{className:"px-3 py-2 text-xs font-medium text-gray-900 dark:text-gray-100 text-right tabular-nums",children:t.composite.toFixed(2)})]})}function D({stratum:t,currency:r}){return e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800/60",children:[e.jsxs("span",{className:"text-xs font-semibold text-gray-800 dark:text-gray-200",children:[t.intent," × ",t.size_band]}),e.jsx(k,{label:t.cell_verdict,className:T[t.cell_verdict]})]}),e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-1.5 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Model"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"n"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Success (90% CI)"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Cost / outcome"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Coverage"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Composite"})]})}),e.jsx("tbody",{children:t.models.map(o=>e.jsx(O,{row:o,isWinner:o.model===t.winner,currency:r},o.model))})]})})]})}function W(){const{currency:t}=C(),[r,o]=p.useState("all"),{data:s,isLoading:a,error:l}=w({queryKey:["benchmark",r],queryFn:()=>P(r),staleTime:6e4}),i=s==null?void 0:s.report;return e.jsxs("section",{className:"space-y-3 pt-2 border-t border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(m,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Which model wins"}),e.jsx("span",{className:"text-xs text-gray-500",children:"cost per successful outcome, per task type — from your own history"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Benchmark period",children:I.map(d=>e.jsx("button",{type:"button",onClick:()=>o(d.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${d.id===r?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:d.label},d.id))})]}),a&&e.jsx(S,{message:"Analyzing your model history..."}),l&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load benchmark: ",l instanceof Error?l.message:"Unknown error"]}),!a&&!l&&i&&i.coverage.sessions_total===0&&e.jsx($,{icon:e.jsx(m,{size:28}),title:"Not enough history to compare models yet",description:"Once you've run a few sessions across more than one model, this panel compares them by task type — honestly."}),!a&&!l&&i&&i.coverage.sessions_total>0&&e.jsxs(e.Fragment,{children:[e.jsx(q,{report:i}),e.jsx(B,{report:i,currency:t}),i.strata.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Per task type (intent × size)"}),i.strata.map(d=>e.jsx(D,{stratum:d,currency:t},`${d.intent}:${d.size_band}`))]})]})]})}const G=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function _(t){return Number.isFinite(t)?`${(t>1?t:t*100).toFixed(1)}%`:"—"}function c({label:t,align:r="left",hint:o}){return e.jsx("th",{className:`px-3 py-2 text-[10px] uppercase tracking-wider text-gray-500 font-medium ${r==="right"?"text-right":"text-left"}`,title:o,children:t})}function K({row:t,showProviderChip:r,currency:o,onSelect:s}){const a=!!s;return e.jsxs("tr",{onClick:s?()=>s(t):void 0,className:`border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40 ${a?"cursor-pointer":""}`,title:a?"Click to filter the dashboard to this (provider, model)":void 0,children:[e.jsx("td",{className:"px-3 py-2",children:e.jsxs("div",{className:"inline-flex items-center gap-2 min-w-0",children:[r&&e.jsx(z,{provider:t.provider}),e.jsx("span",{className:"text-xs text-gray-800 dark:text-gray-200 truncate",title:t.model,children:R(t.model)})]})}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:N(t.sessions)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:N(t.calls)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.one_shot_pct)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.retry_rate)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.cache_hit_rate)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:y(t.cost_per_call,o)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:y(t.cost_per_session,o)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums font-medium text-gray-900 dark:text-gray-100",children:y(t.total_cost,o)})]})}function U(t){const r=new Map;for(const s of t){const a=r.get(s.model)??{sessions:0,calls:0,one_shot_sessions:0,assistant_msgs:0,cache_read_proxy:0,cache_total_proxy:0,total_cost:0,total_tokens:0};a.sessions+=s.sessions,a.calls+=s.calls,a.total_cost+=s.total_cost,a.total_tokens+=s.total_tokens,a.one_shot_sessions+=(s.one_shot_pct??0)*s.sessions,a.assistant_msgs+=(1+(s.retry_rate??0))*s.sessions,a.cache_read_proxy+=(s.cache_hit_rate??0)*s.calls,a.cache_total_proxy+=s.calls,r.set(s.model,a)}const o=[];for(const[s,a]of r){const l=a.sessions,i=a.calls;o.push({model:s,provider:"(combined)",sessions:l,calls:i,one_shot_pct:l?a.one_shot_sessions/l:0,retry_rate:l?a.assistant_msgs/l-1:0,cache_hit_rate:a.cache_total_proxy?a.cache_read_proxy/a.cache_total_proxy:0,cost_per_call:i?a.total_cost/i:0,cost_per_session:l?a.total_cost/l:0,total_cost:a.total_cost,total_tokens:a.total_tokens})}return o.sort((s,a)=>a.total_cost-s.total_cost),o}function V({mode:t,onChange:r}){const o=[{id:"agent_model",label:"Agent × Model",hint:"One row per (provider, model) — same model under different agents shown separately."},{id:"model_only",label:"Model only",hint:"Sum across providers for the same model id."}];return e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Compare grouping",children:o.map(s=>e.jsx("button",{type:"button",onClick:()=>r(s.id),"aria-pressed":s.id===t,title:s.hint,className:`px-3 py-1.5 text-xs font-medium transition-colors ${s.id===t?"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:s.label},s.id))})}function te(){const{currency:t}=C(),{filters:r,setProviders:o,setModels:s}=E(),[a,l]=p.useState("month"),[i,d]=p.useState("agent_model"),{data:x,isLoading:u,error:g}=w({queryKey:["compare",a,r.providers,r.models],queryFn:()=>F(a,{providers:r.providers,models:r.models}),staleTime:6e4}),b=p.useMemo(()=>{if(!x)return[];let n=x.models;if(r.providers.length>0){const f=new Set(r.providers);n=n.filter(j=>f.has((j.provider??"").toLowerCase()))}if(r.models.length>0){const f=new Set(r.models);n=n.filter(j=>f.has((j.model??"").toLowerCase()))}return n},[x,r]),M=p.useMemo(()=>i==="agent_model"?b:U(b),[b,i]),L=n=>{n.provider&&n.provider!=="(combined)"&&o([n.provider]),n.model&&s([n.model])};return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(m,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Per-model comparison"}),e.jsx("span",{className:"text-xs text-gray-500",children:"sessions, retry, cache, and unit economics side-by-side"})]}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(V,{mode:i,onChange:d}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Compare period",children:G.map(n=>e.jsx("button",{type:"button",onClick:()=>l(n.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${n.id===a?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:n.label},n.id))})]})]}),u&&e.jsx(S,{message:"Loading compare data..."}),g&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load compare data: ",g instanceof Error?g.message:"Unknown error"]}),!u&&!g&&x&&x.models.length===0&&e.jsx($,{icon:e.jsx(m,{size:28}),title:"No sessions in window",description:"Try a wider period, or run a session in this window to populate the comparison."}),!u&&!g&&x&&x.models.length>0&&e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx(c,{label:i==="agent_model"?"Agent × Model":"Model",hint:i==="agent_model"?"Provider chip + model id. Same model under different providers renders as distinct rows.":"Aggregated across all providers per model id."}),e.jsx(c,{label:"Sessions",align:"right"}),e.jsx(c,{label:"Calls",align:"right"}),e.jsx(c,{label:"1-shot %",align:"right",hint:"Sessions resolved in a single user/assistant exchange"}),e.jsx(c,{label:"Retry",align:"right",hint:"(assistant_messages / sessions) − 1"}),e.jsx(c,{label:"Cache %",align:"right",hint:"cache_read / (cache_read + input)"}),e.jsx(c,{label:"$/call",align:"right"}),e.jsx(c,{label:"$/session",align:"right"}),e.jsx(c,{label:"Total",align:"right"})]})}),e.jsx("tbody",{children:M.map(n=>e.jsx(K,{row:n,showProviderChip:i==="agent_model",currency:t,onSelect:i==="agent_model"?L:void 0},i==="agent_model"?`${n.model}|${n.provider}`:`model|${n.model}`))})]})}),e.jsx(W,{})]})}export{U as aggregateByModel,te as default}; +import{r as p,u as w,j as e}from"./react-vendor-B7v2HPaI.js";import{u as C,L as S,ak as P,a as E,al as F}from"./index-DQluCO2S.js";import{E as $}from"./EmptyState-o0gibvhZ.js";import{P as z}from"./ProviderChip-baymKnS_.js";import{b as y,f as R,c as N}from"./format-Co_unrac.js";import{l as m}from"./dashboardTabs-C5ecR5YO.js";import{I as A}from"./IconAlertCircle-h2zR9MWj.js";const I=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function h(t){return t==null||!Number.isFinite(t)?"—":`${(t*100).toFixed(0)}%`}const v={high:"bg-green-500/10 text-green-600 dark:text-green-400",medium:"bg-blue-500/10 text-blue-600 dark:text-blue-400",low:"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",none:"bg-gray-500/10 text-gray-500 dark:text-gray-400"},T={clear:"bg-green-500/10 text-green-600 dark:text-green-400",weak:"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400","insufficient evidence":"bg-gray-500/10 text-gray-500 dark:text-gray-400"};function k({label:t,className:r}){return e.jsx("span",{className:`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${r}`,children:t})}function q({report:t}){const r=t.coverage;return e.jsxs("div",{className:"flex items-start gap-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3 text-blue-800 dark:text-blue-300 text-xs",children:[e.jsx(A,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsxs("span",{children:["Based on ",r.sessions_total.toLocaleString()," sessions you already ran — a natural experiment, not a controlled trial. Success measured on"," ",r.sessions_scored.toLocaleString(),"/",r.sessions_total.toLocaleString()," ","sessions · grade coverage ",h(r.grade_coverage),". Weights: success"," ",t.weights.success,", cost ",t.weights.cost,", effort"," ",t.weights.effort," · ",Math.round(t.ci_level*100),"% CI."]})]})}function B({report:t,currency:r}){const o=t.verdict;return o.winning_model?e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(m,{size:16,className:"text-yellow-500"}),e.jsx("span",{className:"text-sm font-semibold text-gray-900 dark:text-gray-100",children:o.headline}),e.jsx(k,{label:o.confidence,className:v[o.confidence]})]}),e.jsxs("div",{className:"text-xs text-gray-600 dark:text-gray-400 mt-2 tabular-nums",children:[o.cost_per_outcome_usd!==null&&e.jsxs("span",{children:[y(o.cost_per_outcome_usd,r)," / successful outcome"]}),o.runner_up&&e.jsxs("span",{children:[" · runner-up ",o.runner_up]})]})]}):e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(k,{label:"insufficient evidence",className:v.none}),e.jsx("span",{className:"text-sm text-gray-600 dark:text-gray-400",children:"No cross-task winner yet"})]}),o.caveats[0]&&e.jsx("p",{className:"text-xs text-gray-500 mt-2",children:o.caveats[0]})]})}function O({row:t,isWinner:r,currency:o}){const s=t.success_rate.ci_wilson,a=t.cost_per_outcome.point,l=t.qualified?"":"opacity-50";return e.jsxs("tr",{className:`border-t border-gray-200 dark:border-gray-800 ${l}`,children:[e.jsxs("td",{className:"px-3 py-2 text-xs font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap",children:[r&&e.jsx(m,{size:12,className:"inline mr-1 text-yellow-500"}),t.model,!t.qualified&&e.jsx("span",{className:"ml-2 text-[10px] text-gray-400",children:"insufficient evidence"})]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums",children:t.n}),e.jsxs("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:[h(t.success_rate.point),s&&e.jsxs("span",{className:"text-gray-400",children:[" ","[",h(s[0]),"–",h(s[1]),"]"]})]}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:a!==null?`${y(a,o)}/outcome`:"—"}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums",children:h(t.coverage)}),e.jsx("td",{className:"px-3 py-2 text-xs font-medium text-gray-900 dark:text-gray-100 text-right tabular-nums",children:t.composite.toFixed(2)})]})}function D({stratum:t,currency:r}){return e.jsxs("div",{className:"rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:[e.jsxs("div",{className:"flex items-center justify-between gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800/60",children:[e.jsxs("span",{className:"text-xs font-semibold text-gray-800 dark:text-gray-200",children:[t.intent," × ",t.size_band]}),e.jsx(k,{label:t.cell_verdict,className:T[t.cell_verdict]})]}),e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-1.5 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Model"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"n"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Success (90% CI)"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Cost / outcome"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Coverage"}),e.jsx("th",{className:"px-3 py-1.5 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Composite"})]})}),e.jsx("tbody",{children:t.models.map(o=>e.jsx(O,{row:o,isWinner:o.model===t.winner,currency:r},o.model))})]})})]})}function W(){const{currency:t}=C(),[r,o]=p.useState("all"),{data:s,isLoading:a,error:l}=w({queryKey:["benchmark",r],queryFn:()=>P(r),staleTime:6e4}),i=s==null?void 0:s.report;return e.jsxs("section",{className:"space-y-3 pt-2 border-t border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(m,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Which model wins"}),e.jsx("span",{className:"text-xs text-gray-500",children:"cost per successful outcome, per task type — from your own history"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Benchmark period",children:I.map(d=>e.jsx("button",{type:"button",onClick:()=>o(d.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${d.id===r?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:d.label},d.id))})]}),a&&e.jsx(S,{message:"Analyzing your model history..."}),l&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load benchmark: ",l instanceof Error?l.message:"Unknown error"]}),!a&&!l&&i&&i.coverage.sessions_total===0&&e.jsx($,{icon:e.jsx(m,{size:28}),title:"Not enough history to compare models yet",description:"Once you've run a few sessions across more than one model, this panel compares them by task type — honestly."}),!a&&!l&&i&&i.coverage.sessions_total>0&&e.jsxs(e.Fragment,{children:[e.jsx(q,{report:i}),e.jsx(B,{report:i,currency:t}),i.strata.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Per task type (intent × size)"}),i.strata.map(d=>e.jsx(D,{stratum:d,currency:t},`${d.intent}:${d.size_band}`))]})]})]})}const G=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function _(t){return Number.isFinite(t)?`${(t>1?t:t*100).toFixed(1)}%`:"—"}function c({label:t,align:r="left",hint:o}){return e.jsx("th",{className:`px-3 py-2 text-[10px] uppercase tracking-wider text-gray-500 font-medium ${r==="right"?"text-right":"text-left"}`,title:o,children:t})}function K({row:t,showProviderChip:r,currency:o,onSelect:s}){const a=!!s;return e.jsxs("tr",{onClick:s?()=>s(t):void 0,className:`border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40 ${a?"cursor-pointer":""}`,title:a?"Click to filter the dashboard to this (provider, model)":void 0,children:[e.jsx("td",{className:"px-3 py-2",children:e.jsxs("div",{className:"inline-flex items-center gap-2 min-w-0",children:[r&&e.jsx(z,{provider:t.provider}),e.jsx("span",{className:"text-xs text-gray-800 dark:text-gray-200 truncate",title:t.model,children:R(t.model)})]})}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:N(t.sessions)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:N(t.calls)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.one_shot_pct)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.retry_rate)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:_(t.cache_hit_rate)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:y(t.cost_per_call,o)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums text-gray-700 dark:text-gray-300",children:y(t.cost_per_session,o)}),e.jsx("td",{className:"px-3 py-2 text-right text-sm tabular-nums font-medium text-gray-900 dark:text-gray-100",children:y(t.total_cost,o)})]})}function U(t){const r=new Map;for(const s of t){const a=r.get(s.model)??{sessions:0,calls:0,one_shot_sessions:0,assistant_msgs:0,cache_read_proxy:0,cache_total_proxy:0,total_cost:0,total_tokens:0};a.sessions+=s.sessions,a.calls+=s.calls,a.total_cost+=s.total_cost,a.total_tokens+=s.total_tokens,a.one_shot_sessions+=(s.one_shot_pct??0)*s.sessions,a.assistant_msgs+=(1+(s.retry_rate??0))*s.sessions,a.cache_read_proxy+=(s.cache_hit_rate??0)*s.calls,a.cache_total_proxy+=s.calls,r.set(s.model,a)}const o=[];for(const[s,a]of r){const l=a.sessions,i=a.calls;o.push({model:s,provider:"(combined)",sessions:l,calls:i,one_shot_pct:l?a.one_shot_sessions/l:0,retry_rate:l?a.assistant_msgs/l-1:0,cache_hit_rate:a.cache_total_proxy?a.cache_read_proxy/a.cache_total_proxy:0,cost_per_call:i?a.total_cost/i:0,cost_per_session:l?a.total_cost/l:0,total_cost:a.total_cost,total_tokens:a.total_tokens})}return o.sort((s,a)=>a.total_cost-s.total_cost),o}function V({mode:t,onChange:r}){const o=[{id:"agent_model",label:"Agent × Model",hint:"One row per (provider, model) — same model under different agents shown separately."},{id:"model_only",label:"Model only",hint:"Sum across providers for the same model id."}];return e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Compare grouping",children:o.map(s=>e.jsx("button",{type:"button",onClick:()=>r(s.id),"aria-pressed":s.id===t,title:s.hint,className:`px-3 py-1.5 text-xs font-medium transition-colors ${s.id===t?"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:s.label},s.id))})}function te(){const{currency:t}=C(),{filters:r,setProviders:o,setModels:s}=E(),[a,l]=p.useState("month"),[i,d]=p.useState("agent_model"),{data:x,isLoading:u,error:g}=w({queryKey:["compare",a,r.providers,r.models],queryFn:()=>F(a,{providers:r.providers,models:r.models}),staleTime:6e4}),b=p.useMemo(()=>{if(!x)return[];let n=x.models;if(r.providers.length>0){const f=new Set(r.providers);n=n.filter(j=>f.has((j.provider??"").toLowerCase()))}if(r.models.length>0){const f=new Set(r.models);n=n.filter(j=>f.has((j.model??"").toLowerCase()))}return n},[x,r]),M=p.useMemo(()=>i==="agent_model"?b:U(b),[b,i]),L=n=>{n.provider&&n.provider!=="(combined)"&&o([n.provider]),n.model&&s([n.model])};return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(m,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Per-model comparison"}),e.jsx("span",{className:"text-xs text-gray-500",children:"sessions, retry, cache, and unit economics side-by-side"})]}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(V,{mode:i,onChange:d}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Compare period",children:G.map(n=>e.jsx("button",{type:"button",onClick:()=>l(n.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${n.id===a?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:n.label},n.id))})]})]}),u&&e.jsx(S,{message:"Loading compare data..."}),g&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load compare data: ",g instanceof Error?g.message:"Unknown error"]}),!u&&!g&&x&&x.models.length===0&&e.jsx($,{icon:e.jsx(m,{size:28}),title:"No sessions in window",description:"Try a wider period, or run a session in this window to populate the comparison."}),!u&&!g&&x&&x.models.length>0&&e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx(c,{label:i==="agent_model"?"Agent × Model":"Model",hint:i==="agent_model"?"Provider chip + model id. Same model under different providers renders as distinct rows.":"Aggregated across all providers per model id."}),e.jsx(c,{label:"Sessions",align:"right"}),e.jsx(c,{label:"Calls",align:"right"}),e.jsx(c,{label:"1-shot %",align:"right",hint:"Sessions resolved in a single user/assistant exchange"}),e.jsx(c,{label:"Retry",align:"right",hint:"(assistant_messages / sessions) − 1"}),e.jsx(c,{label:"Cache %",align:"right",hint:"cache_read / (cache_read + input)"}),e.jsx(c,{label:"$/call",align:"right"}),e.jsx(c,{label:"$/session",align:"right"}),e.jsx(c,{label:"Total",align:"right"})]})}),e.jsx("tbody",{children:M.map(n=>e.jsx(K,{row:n,showProviderChip:i==="agent_model",currency:t,onSelect:i==="agent_model"?L:void 0},i==="agent_model"?`${n.model}|${n.provider}`:`model|${n.model}`))})]})}),e.jsx(W,{})]})}export{U as aggregateByModel,te as default}; diff --git a/stackunderflow/static/react/assets/ContextReplayTab-DjTyR19Y.js b/stackunderflow/static/react/assets/ContextReplayTab-ixP9t9_Y.js similarity index 98% rename from stackunderflow/static/react/assets/ContextReplayTab-DjTyR19Y.js rename to stackunderflow/static/react/assets/ContextReplayTab-ixP9t9_Y.js index c18b248..3079dc1 100644 --- a/stackunderflow/static/react/assets/ContextReplayTab-DjTyR19Y.js +++ b/stackunderflow/static/react/assets/ContextReplayTab-ixP9t9_Y.js @@ -1 +1 @@ -import{r as d,u as F,j as e}from"./react-vendor-B7v2HPaI.js";import{ab as R,L as T,M as I,T as _,j as z,a3 as P,ad as U}from"./index-DDMGE_ZF.js";import{E as M}from"./EmptyState-o0gibvhZ.js";import{a as k}from"./format-Co_unrac.js";import{I as J,a as Q}from"./IconPlayerSkipForwardFilled-DO-WPkWS.js";function b(a){return a.endsWith(".jsonl")?a.slice(0,-6):a}function E(a,i=12){return a.length>i?`${a.slice(0,i)}…`:a}function K(a){const i=b(a.name),n=(a.title||"").trim();return n?`${n.slice(0,60)} — ${E(i)}`:E(i)}function B(a){return a==="user"?"border-emerald-400 dark:border-emerald-500 text-emerald-700 dark:text-emerald-300":a==="assistant"?"border-indigo-400 dark:border-indigo-500 text-indigo-700 dark:text-indigo-300":"border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"}function H({projectName:a}){var C,q,$;const i=R(typeof window<"u"?window.location.search:""),[n,j]=d.useState(i.session),[h,y]=d.useState(null),c=F({queryKey:["contextreplay","sessions",a],queryFn:()=>P(a)}),g=d.useMemo(()=>{var r;return(((r=c.data)==null?void 0:r.files)??[]).slice().sort((x,p)=>(p.modified??0)-(x.modified??0))},[c.data]);d.useEffect(()=>{if(g.length===0)return;const t=new Set(g.map(r=>b(r.name)));(!n||!t.has(n))&&(j(b(g[0].name)),y(null))},[g,n]);const l=F({queryKey:["contextreplay","events",n],queryFn:()=>U(n),enabled:!!n}),s=((C=l.data)==null?void 0:C.events)??[],f=((q=l.data)==null?void 0:q.total_tokens)??0,w=(($=l.data)==null?void 0:$.warnings)??[];d.useEffect(()=>{if(s.length===0||h!==null||i.seq===null)return;const t=s.findIndex(r=>r.seq===i.seq);t>=0&&y(t)},[s,h,i.seq]);const o=d.useMemo(()=>s.length===0?-1:h===null?s.length-1:Math.min(s.length-1,Math.max(0,h)),[s.length,h]),u=o>=0?s[o]??null:null,v=o>=0?s.slice(0,o+1):[],N=(u==null?void 0:u.cumulative_tokens)??0,S=f>0?Math.min(100,Math.round(N/f*100)):0;d.useEffect(()=>{if(typeof window>"u")return;const t=new URL(window.location.href),r=new URLSearchParams(t.search);n?r.set("session",n):r.delete("session"),u?r.set("seq",String(u.seq)):r.delete("seq");const x=r.toString(),p=`${t.pathname}${x?`?${x}`:""}${t.hash}`,L=`${window.location.pathname}${window.location.search}${window.location.hash}`;p!==L&&window.history.replaceState({},"",p)},[n,u]);const m=t=>{s.length!==0&&y(Math.min(s.length-1,Math.max(0,t)))};return c.isLoading?e.jsx(T,{message:"Loading sessions..."}):c.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load sessions:"," ",c.error instanceof Error?c.error.message:"Unknown error"]}):g.length===0?e.jsx(M,{icon:e.jsx(I,{size:40}),title:"No sessions yet in this project",description:"Once a Claude Code session runs here, you'll be able to scrub through the context the model saw at each turn."}):e.jsxs("div",{className:"space-y-4","data-testid":"context-replay-tab",children:[e.jsxs("div",{className:"flex flex-col lg:flex-row lg:items-center gap-3",children:[e.jsx("select",{value:n??"",onChange:t=>{j(t.target.value||null),y(null)},className:"text-sm rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 min-w-0 max-w-md flex-shrink","aria-label":"Session to replay context for",children:g.map(t=>{const r=b(t.name);return e.jsx("option",{value:r,children:K(t)},r)})}),e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx("button",{type:"button",onClick:()=>m(0),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0,"aria-label":"Jump to first turn",children:e.jsx(J,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(o-1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0||o<=0,"aria-label":"Previous turn",children:e.jsx(_,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(o+1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0||o>=s.length-1,"aria-label":"Next turn",children:e.jsx(z,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(s.length-1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0,"aria-label":"Jump to last turn (full context)",children:e.jsx(Q,{size:14})}),s.length>0&&e.jsxs("span",{className:"text-xs text-gray-500 dark:text-gray-400 ml-2 tabular-nums",children:["turn ",o+1," of ",s.length]})]})]}),l.isLoading?e.jsx("div",{className:"h-14 flex items-center text-xs text-gray-500",children:"Reconstructing context…"}):l.error?e.jsxs("div",{className:"p-3 text-sm text-red-600 dark:text-red-400",children:["Failed to reconstruct context:"," ",l.error instanceof Error?l.error.message:"Unknown error"]}):s.length===0?e.jsx(M,{icon:e.jsx(I,{size:36}),title:"No messages in this session",description:w.length>0?w.join(" · "):"This session has no recorded messages — pick another from the dropdown above."}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"space-y-1.5",children:[e.jsxs("div",{className:"flex items-center justify-between text-xs",children:[e.jsxs("span",{className:"text-gray-600 dark:text-gray-300 tabular-nums",children:["≈ ",k(N)," / ",k(f)," context tokens",e.jsxs("span",{className:"text-gray-400 dark:text-gray-500",children:[" (",S,"%)"]})]}),e.jsx("span",{className:"text-[11px] text-gray-400 dark:text-gray-500",children:"estimate · chars/4 of each turn's text + tool payload"})]}),e.jsx("div",{className:"h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden",children:e.jsx("div",{className:"h-full rounded-full bg-indigo-500 dark:bg-indigo-400 transition-all",style:{width:`${S}%`}})}),e.jsx("input",{type:"range",min:0,max:Math.max(0,s.length-1),value:o<0?0:o,onChange:t=>m(Number(t.target.value)),className:"w-full accent-indigo-500 dark:accent-indigo-400","aria-label":"Context cutoff (seq)"})]}),e.jsx("ul",{className:"space-y-1.5","data-testid":"context-replay-events",children:v.map((t,r)=>{const x=r===v.length-1;return e.jsxs("li",{className:`rounded-md border-l-2 pl-3 pr-2 py-1.5 ${B(t.role)} ${x?"bg-indigo-50/70 dark:bg-indigo-900/20":"bg-gray-50 dark:bg-gray-800/40"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2 text-xs",children:[e.jsxs("span",{className:"font-medium tabular-nums",children:["#",t.seq," ",e.jsx("span",{className:"uppercase tracking-wide",children:t.role})]}),e.jsxs("span",{className:"text-gray-500 dark:text-gray-400 tabular-nums",children:[k(t.cumulative_tokens)," tok"]})]}),t.tool_calls.length>0&&e.jsxs("div",{className:"mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate",children:["tools: ",t.tool_calls.join(", ")]}),t.content_preview&&e.jsx("div",{className:"mt-0.5 text-xs text-gray-700 dark:text-gray-300 line-clamp-2 whitespace-pre-wrap break-words",children:t.content_preview})]},t.seq)})})]})]})}export{H as default}; +import{r as d,u as F,j as e}from"./react-vendor-B7v2HPaI.js";import{ab as R,L as T,M as I,T as _,j as z,a3 as P,ad as U}from"./index-DQluCO2S.js";import{E as M}from"./EmptyState-o0gibvhZ.js";import{a as k}from"./format-Co_unrac.js";import{I as J,a as Q}from"./IconPlayerSkipForwardFilled-DA8tH9T1.js";function b(a){return a.endsWith(".jsonl")?a.slice(0,-6):a}function E(a,i=12){return a.length>i?`${a.slice(0,i)}…`:a}function K(a){const i=b(a.name),n=(a.title||"").trim();return n?`${n.slice(0,60)} — ${E(i)}`:E(i)}function B(a){return a==="user"?"border-emerald-400 dark:border-emerald-500 text-emerald-700 dark:text-emerald-300":a==="assistant"?"border-indigo-400 dark:border-indigo-500 text-indigo-700 dark:text-indigo-300":"border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"}function H({projectName:a}){var C,q,$;const i=R(typeof window<"u"?window.location.search:""),[n,j]=d.useState(i.session),[h,y]=d.useState(null),c=F({queryKey:["contextreplay","sessions",a],queryFn:()=>P(a)}),g=d.useMemo(()=>{var r;return(((r=c.data)==null?void 0:r.files)??[]).slice().sort((x,p)=>(p.modified??0)-(x.modified??0))},[c.data]);d.useEffect(()=>{if(g.length===0)return;const t=new Set(g.map(r=>b(r.name)));(!n||!t.has(n))&&(j(b(g[0].name)),y(null))},[g,n]);const l=F({queryKey:["contextreplay","events",n],queryFn:()=>U(n),enabled:!!n}),s=((C=l.data)==null?void 0:C.events)??[],f=((q=l.data)==null?void 0:q.total_tokens)??0,w=(($=l.data)==null?void 0:$.warnings)??[];d.useEffect(()=>{if(s.length===0||h!==null||i.seq===null)return;const t=s.findIndex(r=>r.seq===i.seq);t>=0&&y(t)},[s,h,i.seq]);const o=d.useMemo(()=>s.length===0?-1:h===null?s.length-1:Math.min(s.length-1,Math.max(0,h)),[s.length,h]),u=o>=0?s[o]??null:null,v=o>=0?s.slice(0,o+1):[],N=(u==null?void 0:u.cumulative_tokens)??0,S=f>0?Math.min(100,Math.round(N/f*100)):0;d.useEffect(()=>{if(typeof window>"u")return;const t=new URL(window.location.href),r=new URLSearchParams(t.search);n?r.set("session",n):r.delete("session"),u?r.set("seq",String(u.seq)):r.delete("seq");const x=r.toString(),p=`${t.pathname}${x?`?${x}`:""}${t.hash}`,L=`${window.location.pathname}${window.location.search}${window.location.hash}`;p!==L&&window.history.replaceState({},"",p)},[n,u]);const m=t=>{s.length!==0&&y(Math.min(s.length-1,Math.max(0,t)))};return c.isLoading?e.jsx(T,{message:"Loading sessions..."}):c.error?e.jsxs("div",{className:"p-4 text-sm text-red-600 dark:text-red-400",children:["Failed to load sessions:"," ",c.error instanceof Error?c.error.message:"Unknown error"]}):g.length===0?e.jsx(M,{icon:e.jsx(I,{size:40}),title:"No sessions yet in this project",description:"Once a Claude Code session runs here, you'll be able to scrub through the context the model saw at each turn."}):e.jsxs("div",{className:"space-y-4","data-testid":"context-replay-tab",children:[e.jsxs("div",{className:"flex flex-col lg:flex-row lg:items-center gap-3",children:[e.jsx("select",{value:n??"",onChange:t=>{j(t.target.value||null),y(null)},className:"text-sm rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 min-w-0 max-w-md flex-shrink","aria-label":"Session to replay context for",children:g.map(t=>{const r=b(t.name);return e.jsx("option",{value:r,children:K(t)},r)})}),e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx("button",{type:"button",onClick:()=>m(0),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0,"aria-label":"Jump to first turn",children:e.jsx(J,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(o-1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0||o<=0,"aria-label":"Previous turn",children:e.jsx(_,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(o+1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0||o>=s.length-1,"aria-label":"Next turn",children:e.jsx(z,{size:14})}),e.jsx("button",{type:"button",onClick:()=>m(s.length-1),className:"p-1.5 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-40",disabled:s.length===0,"aria-label":"Jump to last turn (full context)",children:e.jsx(Q,{size:14})}),s.length>0&&e.jsxs("span",{className:"text-xs text-gray-500 dark:text-gray-400 ml-2 tabular-nums",children:["turn ",o+1," of ",s.length]})]})]}),l.isLoading?e.jsx("div",{className:"h-14 flex items-center text-xs text-gray-500",children:"Reconstructing context…"}):l.error?e.jsxs("div",{className:"p-3 text-sm text-red-600 dark:text-red-400",children:["Failed to reconstruct context:"," ",l.error instanceof Error?l.error.message:"Unknown error"]}):s.length===0?e.jsx(M,{icon:e.jsx(I,{size:36}),title:"No messages in this session",description:w.length>0?w.join(" · "):"This session has no recorded messages — pick another from the dropdown above."}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"space-y-1.5",children:[e.jsxs("div",{className:"flex items-center justify-between text-xs",children:[e.jsxs("span",{className:"text-gray-600 dark:text-gray-300 tabular-nums",children:["≈ ",k(N)," / ",k(f)," context tokens",e.jsxs("span",{className:"text-gray-400 dark:text-gray-500",children:[" (",S,"%)"]})]}),e.jsx("span",{className:"text-[11px] text-gray-400 dark:text-gray-500",children:"estimate · chars/4 of each turn's text + tool payload"})]}),e.jsx("div",{className:"h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden",children:e.jsx("div",{className:"h-full rounded-full bg-indigo-500 dark:bg-indigo-400 transition-all",style:{width:`${S}%`}})}),e.jsx("input",{type:"range",min:0,max:Math.max(0,s.length-1),value:o<0?0:o,onChange:t=>m(Number(t.target.value)),className:"w-full accent-indigo-500 dark:accent-indigo-400","aria-label":"Context cutoff (seq)"})]}),e.jsx("ul",{className:"space-y-1.5","data-testid":"context-replay-events",children:v.map((t,r)=>{const x=r===v.length-1;return e.jsxs("li",{className:`rounded-md border-l-2 pl-3 pr-2 py-1.5 ${B(t.role)} ${x?"bg-indigo-50/70 dark:bg-indigo-900/20":"bg-gray-50 dark:bg-gray-800/40"}`,children:[e.jsxs("div",{className:"flex items-center justify-between gap-2 text-xs",children:[e.jsxs("span",{className:"font-medium tabular-nums",children:["#",t.seq," ",e.jsx("span",{className:"uppercase tracking-wide",children:t.role})]}),e.jsxs("span",{className:"text-gray-500 dark:text-gray-400 tabular-nums",children:[k(t.cumulative_tokens)," tok"]})]}),t.tool_calls.length>0&&e.jsxs("div",{className:"mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate",children:["tools: ",t.tool_calls.join(", ")]}),t.content_preview&&e.jsx("div",{className:"mt-0.5 text-xs text-gray-700 dark:text-gray-300 line-clamp-2 whitespace-pre-wrap break-words",children:t.content_preview})]},t.seq)})})]})]})}export{H as default}; diff --git a/stackunderflow/static/react/assets/CostTab-BAdmE1EC.js b/stackunderflow/static/react/assets/CostTab-DCJQfNNf.js similarity index 99% rename from stackunderflow/static/react/assets/CostTab-BAdmE1EC.js rename to stackunderflow/static/react/assets/CostTab-DCJQfNNf.js index 28ae4e8..1bcb173 100644 --- a/stackunderflow/static/react/assets/CostTab-BAdmE1EC.js +++ b/stackunderflow/static/react/assets/CostTab-DCJQfNNf.js @@ -1,4 +1,4 @@ -import{r as p,u as pe,j as e,m as Oe}from"./react-vendor-B7v2HPaI.js";import{a as U,o as he,g as ne}from"./ProjectDashboard-DRHQbkve.js";import{h as Pe,u as O,a as Be,L as Je,m as ie,B as xe,n as ze,ae as et,p as z,i as X,j as Ie,N as Ae,I as Ke,af as tt}from"./index-DDMGE_ZF.js";import{b as S,c as R,a as B,f as H}from"./format-Co_unrac.js";import{I as rt,T as st,C as at,c as ot,d as nt}from"./TokenCompositionDonut-DsJdUSqI.js";import{I as ge,E as me}from"./EstimatedCostMarker-DkSj8xDc.js";import{u as Q,E as le,C as it,a as Ne}from"./chartTheme-GpkpBpop.js";import{R as Z,B as J,C as ee,X as te,Y as re,T as se,b as ae,d as Ue,g as He,L as lt}from"./recharts-C8DDeE7E.js";import{I as dt,a as ct}from"./IconArrowUp-CszCUTPq.js";import{a as xt,I as ve}from"./FilterBar-DuduzIn6.js";import"./EmptyState-o0gibvhZ.js";import"./dashboardTabs-CJWioGLA.js";import"./IconHash-Bsu2htWI.js";import"./IconTrendingUp-DvwC6rkL.js";/** +import{r as p,u as pe,j as e,m as Oe}from"./react-vendor-B7v2HPaI.js";import{a as U,o as he,g as ne}from"./ProjectDashboard-DAsn11K-.js";import{h as Pe,u as O,a as Be,L as Je,m as ie,B as xe,n as ze,ae as et,p as z,i as X,j as Ie,N as Ae,I as Ke,af as tt}from"./index-DQluCO2S.js";import{b as S,c as R,a as B,f as H}from"./format-Co_unrac.js";import{I as rt,T as st,C as at,c as ot,d as nt}from"./TokenCompositionDonut-DTEMWIwY.js";import{I as ge,E as me}from"./EstimatedCostMarker-BpsBXEjf.js";import{u as Q,E as le,C as it,a as Ne}from"./chartTheme-GpkpBpop.js";import{R as Z,B as J,C as ee,X as te,Y as re,T as se,b as ae,d as Ue,g as He,L as lt}from"./recharts-C8DDeE7E.js";import{I as dt,a as ct}from"./IconArrowUp-D8NJ3QQF.js";import{a as xt,I as ve}from"./FilterBar-BS6lhcC1.js";import"./EmptyState-o0gibvhZ.js";import"./dashboardTabs-C5ecR5YO.js";import"./IconHash-cdeuBxnj.js";import"./IconTrendingUp-D1iuYAG9.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/DataTable-D0JcWuTA.js b/stackunderflow/static/react/assets/DataTable-DCEy-2Es.js similarity index 95% rename from stackunderflow/static/react/assets/DataTable-D0JcWuTA.js rename to stackunderflow/static/react/assets/DataTable-DCEy-2Es.js index 432a30a..582f37e 100644 --- a/stackunderflow/static/react/assets/DataTable-D0JcWuTA.js +++ b/stackunderflow/static/react/assets/DataTable-DCEy-2Es.js @@ -1 +1 @@ -import{r as o,j as r}from"./react-vendor-B7v2HPaI.js";import{b as $}from"./index-DDMGE_ZF.js";import{I as A}from"./ProjectDashboard-DRHQbkve.js";import{I as K,a as O}from"./IconArrowUp-CszCUTPq.js";function H({columns:d,data:f,keyFn:w,searchable:S,searchPlaceholder:D,searchFn:p,onRowClick:x,perPageOptions:I=[25,50,100],defaultPerPage:P=25,exportFilename:u,exportFn:m,emptyMessage:V="No data"}){const[y,L]=o.useState(""),[i,M]=o.useState(null),[b,j]=o.useState("desc"),[s,l]=o.useState(1),[n,U]=o.useState(P),c=o.useMemo(()=>{let e=f;if(y&&p){const t=y.toLowerCase();e=e.filter(a=>p(a,t))}if(i){const t=d.find(a=>a.key===i);if(t!=null&&t.sortValue){const a=t.sortValue;e=[...e].sort((g,E)=>{const k=a(g),N=a(E);return kN?b==="asc"?1:-1:0})}}return e},[f,y,p,i,b,d]),h=Math.ceil(c.length/n),v=c.slice((s-1)*n,s*n),z=e=>{const t=d.find(a=>a.key===e);t!=null&&t.sortValue&&(i===e?j(a=>a==="asc"?"desc":"asc"):(M(e),j("desc")),l(1))},C=()=>{if(!m||!u)return;const e=m(c),t=new Blob([e],{type:"text/csv"}),a=URL.createObjectURL(t),g=document.createElement("a");g.href=a,g.download=u,g.click(),URL.revokeObjectURL(a)};return r.jsxs("div",{className:"space-y-3",children:[r.jsxs("div",{className:"flex items-center gap-3",children:[S&&r.jsxs("div",{className:"relative flex-1 max-w-xs",children:[r.jsx($,{size:14,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),r.jsx("input",{type:"text",value:y,onChange:e=>{L(e.target.value),l(1)},placeholder:D??"Search...",className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded pl-8 pr-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500"})]}),r.jsx("div",{className:"flex-1"}),r.jsx("select",{value:n,onChange:e=>{U(Number(e.target.value)),l(1)},className:"bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300",children:I.map(e=>r.jsx("option",{value:e,children:e},e))}),m&&u&&r.jsxs("button",{onClick:C,className:"flex items-center gap-1 px-2 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:border-gray-400 dark:hover:border-gray-600",children:[r.jsx(A,{size:14})," CSV"]})]}),r.jsx("div",{className:"bg-gray-100/50 dark:bg-gray-800/30 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:r.jsx("div",{className:"overflow-x-auto",children:r.jsxs("table",{className:"w-full text-sm",children:[r.jsx("thead",{children:r.jsx("tr",{className:"border-b border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider",children:d.map(e=>r.jsxs("th",{className:`px-4 py-2.5 ${e.align==="right"?"text-right":e.align==="center"?"text-center":"text-left"} ${e.sortValue?"cursor-pointer hover:text-gray-800 dark:hover:text-gray-200":""}`,style:e.width?{width:e.width}:void 0,onClick:()=>e.sortValue&&z(e.key),children:[e.label,i===e.key&&(b==="asc"?r.jsx(K,{size:12,className:"inline ml-0.5"}):r.jsx(O,{size:12,className:"inline ml-0.5"}))]},e.key))})}),r.jsx("tbody",{children:v.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:d.length,className:"px-4 py-8 text-center text-gray-500",children:V})}):v.map(e=>r.jsx("tr",{className:`border-b border-gray-200/50 dark:border-gray-800/50 ${x?"hover:bg-gray-100/70 dark:hover:bg-gray-800/50 cursor-pointer":""}`,onClick:()=>x==null?void 0:x(e),children:d.map(t=>r.jsx("td",{className:`px-4 py-2.5 ${t.align==="right"?"text-right":t.align==="center"?"text-center":"text-left"}`,children:t.render(e)},t.key))},w(e)))})]})})}),h>1&&r.jsxs("div",{className:"flex items-center justify-between text-xs text-gray-600 dark:text-gray-400",children:[r.jsxs("span",{children:[(s-1)*n+1,"-",Math.min(s*n,c.length)," of ",c.length]}),r.jsxs("div",{className:"flex items-center gap-2",children:[r.jsx("button",{onClick:()=>l(e=>Math.max(1,e-1)),disabled:s<=1,className:"px-2 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 disabled:opacity-50",children:"Prev"}),r.jsxs("span",{children:[s,"/",h]}),r.jsx("button",{onClick:()=>l(e=>Math.min(h,e+1)),disabled:s>=h,className:"px-2 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 disabled:opacity-50",children:"Next"})]})]})]})}export{H as D}; +import{r as o,j as r}from"./react-vendor-B7v2HPaI.js";import{b as $}from"./index-DQluCO2S.js";import{I as A}from"./ProjectDashboard-DAsn11K-.js";import{I as K,a as O}from"./IconArrowUp-D8NJ3QQF.js";function H({columns:d,data:f,keyFn:w,searchable:S,searchPlaceholder:D,searchFn:p,onRowClick:x,perPageOptions:I=[25,50,100],defaultPerPage:P=25,exportFilename:u,exportFn:m,emptyMessage:V="No data"}){const[y,L]=o.useState(""),[i,M]=o.useState(null),[b,j]=o.useState("desc"),[s,l]=o.useState(1),[n,U]=o.useState(P),c=o.useMemo(()=>{let e=f;if(y&&p){const t=y.toLowerCase();e=e.filter(a=>p(a,t))}if(i){const t=d.find(a=>a.key===i);if(t!=null&&t.sortValue){const a=t.sortValue;e=[...e].sort((g,E)=>{const k=a(g),N=a(E);return kN?b==="asc"?1:-1:0})}}return e},[f,y,p,i,b,d]),h=Math.ceil(c.length/n),v=c.slice((s-1)*n,s*n),z=e=>{const t=d.find(a=>a.key===e);t!=null&&t.sortValue&&(i===e?j(a=>a==="asc"?"desc":"asc"):(M(e),j("desc")),l(1))},C=()=>{if(!m||!u)return;const e=m(c),t=new Blob([e],{type:"text/csv"}),a=URL.createObjectURL(t),g=document.createElement("a");g.href=a,g.download=u,g.click(),URL.revokeObjectURL(a)};return r.jsxs("div",{className:"space-y-3",children:[r.jsxs("div",{className:"flex items-center gap-3",children:[S&&r.jsxs("div",{className:"relative flex-1 max-w-xs",children:[r.jsx($,{size:14,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),r.jsx("input",{type:"text",value:y,onChange:e=>{L(e.target.value),l(1)},placeholder:D??"Search...",className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded pl-8 pr-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500"})]}),r.jsx("div",{className:"flex-1"}),r.jsx("select",{value:n,onChange:e=>{U(Number(e.target.value)),l(1)},className:"bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300",children:I.map(e=>r.jsx("option",{value:e,children:e},e))}),m&&u&&r.jsxs("button",{onClick:C,className:"flex items-center gap-1 px-2 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:border-gray-400 dark:hover:border-gray-600",children:[r.jsx(A,{size:14})," CSV"]})]}),r.jsx("div",{className:"bg-gray-100/50 dark:bg-gray-800/30 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:r.jsx("div",{className:"overflow-x-auto",children:r.jsxs("table",{className:"w-full text-sm",children:[r.jsx("thead",{children:r.jsx("tr",{className:"border-b border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider",children:d.map(e=>r.jsxs("th",{className:`px-4 py-2.5 ${e.align==="right"?"text-right":e.align==="center"?"text-center":"text-left"} ${e.sortValue?"cursor-pointer hover:text-gray-800 dark:hover:text-gray-200":""}`,style:e.width?{width:e.width}:void 0,onClick:()=>e.sortValue&&z(e.key),children:[e.label,i===e.key&&(b==="asc"?r.jsx(K,{size:12,className:"inline ml-0.5"}):r.jsx(O,{size:12,className:"inline ml-0.5"}))]},e.key))})}),r.jsx("tbody",{children:v.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:d.length,className:"px-4 py-8 text-center text-gray-500",children:V})}):v.map(e=>r.jsx("tr",{className:`border-b border-gray-200/50 dark:border-gray-800/50 ${x?"hover:bg-gray-100/70 dark:hover:bg-gray-800/50 cursor-pointer":""}`,onClick:()=>x==null?void 0:x(e),children:d.map(t=>r.jsx("td",{className:`px-4 py-2.5 ${t.align==="right"?"text-right":t.align==="center"?"text-center":"text-left"}`,children:t.render(e)},t.key))},w(e)))})]})})}),h>1&&r.jsxs("div",{className:"flex items-center justify-between text-xs text-gray-600 dark:text-gray-400",children:[r.jsxs("span",{children:[(s-1)*n+1,"-",Math.min(s*n,c.length)," of ",c.length]}),r.jsxs("div",{className:"flex items-center gap-2",children:[r.jsx("button",{onClick:()=>l(e=>Math.max(1,e-1)),disabled:s<=1,className:"px-2 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 disabled:opacity-50",children:"Prev"}),r.jsxs("span",{children:[s,"/",h]}),r.jsx("button",{onClick:()=>l(e=>Math.min(h,e+1)),disabled:s>=h,className:"px-2 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 disabled:opacity-50",children:"Next"})]})]})]})}export{H as D}; diff --git a/stackunderflow/static/react/assets/EstimatedCostMarker-DkSj8xDc.js b/stackunderflow/static/react/assets/EstimatedCostMarker-BpsBXEjf.js similarity index 90% rename from stackunderflow/static/react/assets/EstimatedCostMarker-DkSj8xDc.js rename to stackunderflow/static/react/assets/EstimatedCostMarker-BpsBXEjf.js index f03e202..fbe321c 100644 --- a/stackunderflow/static/react/assets/EstimatedCostMarker-DkSj8xDc.js +++ b/stackunderflow/static/react/assets/EstimatedCostMarker-BpsBXEjf.js @@ -1,4 +1,4 @@ -import{h as r}from"./index-DDMGE_ZF.js";import{j as o}from"./react-vendor-B7v2HPaI.js";/** +import{h as r}from"./index-DQluCO2S.js";import{j as o}from"./react-vendor-B7v2HPaI.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/FilterBar-DuduzIn6.js b/stackunderflow/static/react/assets/FilterBar-BS6lhcC1.js similarity index 98% rename from stackunderflow/static/react/assets/FilterBar-DuduzIn6.js rename to stackunderflow/static/react/assets/FilterBar-BS6lhcC1.js index 5927767..301a857 100644 --- a/stackunderflow/static/react/assets/FilterBar-DuduzIn6.js +++ b/stackunderflow/static/react/assets/FilterBar-BS6lhcC1.js @@ -1,4 +1,4 @@ -import{u as $,r as M,j as r}from"./react-vendor-B7v2HPaI.js";import{h as v,a as F,m as h,n as P,B,o as I}from"./index-DDMGE_ZF.js";import{f as b}from"./format-Co_unrac.js";/** +import{u as $,r as M,j as r}from"./react-vendor-B7v2HPaI.js";import{h as v,a as F,m as h,n as P,B,o as I}from"./index-DQluCO2S.js";import{f as b}from"./format-Co_unrac.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/ForksTab-ovi_pavq.js b/stackunderflow/static/react/assets/ForksTab-ryk22jFG.js similarity index 96% rename from stackunderflow/static/react/assets/ForksTab-ovi_pavq.js rename to stackunderflow/static/react/assets/ForksTab-ryk22jFG.js index 6c08a75..9985a50 100644 --- a/stackunderflow/static/react/assets/ForksTab-ovi_pavq.js +++ b/stackunderflow/static/react/assets/ForksTab-ryk22jFG.js @@ -1 +1 @@ -import{r as y,u as b,j as e}from"./react-vendor-B7v2HPaI.js";import{u as f,L as j,z as k,p as w,B as p,an as N}from"./index-DDMGE_ZF.js";import{E as _}from"./EmptyState-o0gibvhZ.js";import{b as c,a as u}from"./format-Co_unrac.js";import{n as g,m as v}from"./dashboardTabs-CJWioGLA.js";import{I as m}from"./IconRobot-BQwymFKA.js";const $=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function h(t){return Number.isFinite(t)?`${(t*100).toFixed(1)}%`:"0%"}function F(t){return t===null||!Number.isFinite(t)?"—":t>=86400?`${(t/86400).toFixed(1)}d`:t>=3600?`${(t/3600).toFixed(1)}h`:t>=60?`${Math.round(t/60)}m`:`${Math.round(t)}s`}function x({icon:t,title:s,value:d,sub:o,color:a}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[e.jsx("span",{className:a,children:t}),e.jsx("span",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:s})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 tabular-nums",children:d}),o&&e.jsx("div",{className:"text-xs text-gray-500 mt-1 tabular-nums",children:o})]})}function S({warning:t}){return e.jsxs("div",{className:"flex items-start gap-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-md p-3 text-yellow-800 dark:text-yellow-300 text-xs",children:[e.jsx(w,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsx("span",{children:t})]})}function z({branch:t,currency:s}){return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap",children:t.session_id.slice(0,8)}),e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-600 dark:text-gray-400 whitespace-nowrap",children:t.branch_head_uuid.slice(0,8)}),e.jsx("td",{className:"px-3 py-2",children:t.sidechain?e.jsx(p,{color:"purple",size:"sm",children:"sidechain"}):e.jsx(p,{color:"gray",size:"sm",children:"branch"})}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.message_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:u(t.token_total)}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:F(t.gap_seconds)}),e.jsx("td",{className:"px-3 py-2 text-sm tabular-nums text-gray-900 dark:text-gray-100 font-medium text-right whitespace-nowrap",children:c(t.cost_usd,s)})]})}function A({projectName:t}){const{currency:s}=f(),[d,o]=y.useState("all"),{data:a,isLoading:l,error:i}=b({queryKey:["forks",t,d],queryFn:()=>N(d),staleTime:6e4}),r=a==null?void 0:a.report;return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(g,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Forks & Sidechains"}),e.jsx("span",{className:"text-xs text-gray-500",children:"subagent spend + branches you started then dropped"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Forks period",children:$.map(n=>e.jsx("button",{type:"button",onClick:()=>o(n.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${n.id===d?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:n.label},n.id))})]}),(a==null?void 0:a.warning)&&e.jsx(S,{warning:a.warning}),l&&e.jsx(j,{message:"Analyzing forks..."}),i&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load forks: ",i instanceof Error?i.message:"Unknown error"]}),!l&&!i&&r&&e.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-4 gap-3",children:[e.jsx(x,{icon:e.jsx(m,{size:16}),title:"Sidechain cost",value:c(r.sidechain_cost_usd,s),sub:`${h(r.sidechain_cost_share)} of ${c(r.total_cost_usd,s)} · ${r.sidechain_message_count.toLocaleString()} msgs`,color:"text-purple-500"}),e.jsx(x,{icon:e.jsx(m,{size:16}),title:"Sidechain tokens",value:u(r.sidechain_token_total),sub:`${h(r.sidechain_token_share)} of all tokens`,color:"text-purple-500"}),e.jsx(x,{icon:e.jsx(v,{size:16}),title:"Fork points",value:r.fork_point_count.toLocaleString(),sub:"conversation branched here",color:"text-blue-500"}),e.jsx(x,{icon:e.jsx(k,{size:16}),title:"Abandoned spend",value:c(r.abandoned_cost_usd,s),sub:`${r.abandoned_branch_count.toLocaleString()} dropped ${r.abandoned_branch_count===1?"branch":"branches"}`,color:"text-yellow-500"})]}),!l&&!i&&r&&r.abandoned_branches.length===0&&e.jsx(_,{icon:e.jsx(g,{size:28}),title:"No abandoned branches in window",description:"Nothing was branched and dropped in this period — or the sunk cost was below the noise floor. Try a wider period."}),!l&&!i&&r&&r.abandoned_branches.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Top abandoned branches"}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Session"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Branch"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Kind"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Msgs"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Tokens"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Dropped before end"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Cost"})]})}),e.jsx("tbody",{children:r.abandoned_branches.map(n=>e.jsx(z,{branch:n,currency:s},`${n.session_id}:${n.branch_head_uuid}`))})]})})]})]})}export{A as default}; +import{r as y,u as b,j as e}from"./react-vendor-B7v2HPaI.js";import{u as f,L as j,z as k,p as w,B as p,an as N}from"./index-DQluCO2S.js";import{E as _}from"./EmptyState-o0gibvhZ.js";import{b as c,a as u}from"./format-Co_unrac.js";import{n as g,m as v}from"./dashboardTabs-C5ecR5YO.js";import{I as m}from"./IconRobot-DuCKb11z.js";const $=[{id:"today",label:"Today"},{id:"week",label:"7d"},{id:"month",label:"30d"},{id:"all",label:"All"}];function h(t){return Number.isFinite(t)?`${(t*100).toFixed(1)}%`:"0%"}function F(t){return t===null||!Number.isFinite(t)?"—":t>=86400?`${(t/86400).toFixed(1)}d`:t>=3600?`${(t/3600).toFixed(1)}h`:t>=60?`${Math.round(t/60)}m`:`${Math.round(t)}s`}function x({icon:t,title:s,value:d,sub:o,color:a}){return e.jsxs("div",{className:"bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[e.jsx("span",{className:a,children:t}),e.jsx("span",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:s})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 tabular-nums",children:d}),o&&e.jsx("div",{className:"text-xs text-gray-500 mt-1 tabular-nums",children:o})]})}function S({warning:t}){return e.jsxs("div",{className:"flex items-start gap-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-md p-3 text-yellow-800 dark:text-yellow-300 text-xs",children:[e.jsx(w,{size:14,className:"flex-shrink-0 mt-0.5"}),e.jsx("span",{children:t})]})}function z({branch:t,currency:s}){return e.jsxs("tr",{className:"border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40",children:[e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap",children:t.session_id.slice(0,8)}),e.jsx("td",{className:"px-3 py-2 text-xs font-mono text-gray-600 dark:text-gray-400 whitespace-nowrap",children:t.branch_head_uuid.slice(0,8)}),e.jsx("td",{className:"px-3 py-2",children:t.sidechain?e.jsx(p,{color:"purple",size:"sm",children:"sidechain"}):e.jsx(p,{color:"gray",size:"sm",children:"branch"})}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:t.message_count}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:u(t.token_total)}),e.jsx("td",{className:"px-3 py-2 text-xs text-gray-600 dark:text-gray-400 text-right tabular-nums whitespace-nowrap",children:F(t.gap_seconds)}),e.jsx("td",{className:"px-3 py-2 text-sm tabular-nums text-gray-900 dark:text-gray-100 font-medium text-right whitespace-nowrap",children:c(t.cost_usd,s)})]})}function A({projectName:t}){const{currency:s}=f(),[d,o]=y.useState("all"),{data:a,isLoading:l,error:i}=b({queryKey:["forks",t,d],queryFn:()=>N(d),staleTime:6e4}),r=a==null?void 0:a.report;return e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between gap-3 flex-wrap",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(g,{size:16,className:"text-gray-500"}),e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:"Forks & Sidechains"}),e.jsx("span",{className:"text-xs text-gray-500",children:"subagent spend + branches you started then dropped"})]}),e.jsx("div",{className:"inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden",role:"group","aria-label":"Forks period",children:$.map(n=>e.jsx("button",{type:"button",onClick:()=>o(n.id),className:`px-3 py-1.5 text-xs font-medium transition-colors ${n.id===d?"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400":"bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"}`,children:n.label},n.id))})]}),(a==null?void 0:a.warning)&&e.jsx(S,{warning:a.warning}),l&&e.jsx(j,{message:"Analyzing forks..."}),i&&e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm",children:["Failed to load forks: ",i instanceof Error?i.message:"Unknown error"]}),!l&&!i&&r&&e.jsxs("div",{className:"grid grid-cols-2 lg:grid-cols-4 gap-3",children:[e.jsx(x,{icon:e.jsx(m,{size:16}),title:"Sidechain cost",value:c(r.sidechain_cost_usd,s),sub:`${h(r.sidechain_cost_share)} of ${c(r.total_cost_usd,s)} · ${r.sidechain_message_count.toLocaleString()} msgs`,color:"text-purple-500"}),e.jsx(x,{icon:e.jsx(m,{size:16}),title:"Sidechain tokens",value:u(r.sidechain_token_total),sub:`${h(r.sidechain_token_share)} of all tokens`,color:"text-purple-500"}),e.jsx(x,{icon:e.jsx(v,{size:16}),title:"Fork points",value:r.fork_point_count.toLocaleString(),sub:"conversation branched here",color:"text-blue-500"}),e.jsx(x,{icon:e.jsx(k,{size:16}),title:"Abandoned spend",value:c(r.abandoned_cost_usd,s),sub:`${r.abandoned_branch_count.toLocaleString()} dropped ${r.abandoned_branch_count===1?"branch":"branches"}`,color:"text-yellow-500"})]}),!l&&!i&&r&&r.abandoned_branches.length===0&&e.jsx(_,{icon:e.jsx(g,{size:28}),title:"No abandoned branches in window",description:"Nothing was branched and dropped in this period — or the sunk cost was below the noise floor. Try a wider period."}),!l&&!i&&r&&r.abandoned_branches.length>0&&e.jsxs("div",{className:"space-y-2",children:[e.jsx("h3",{className:"text-xs uppercase tracking-wider text-gray-500 font-medium",children:"Top abandoned branches"}),e.jsx("div",{className:"overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Session"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Branch"}),e.jsx("th",{className:"px-3 py-2 text-left text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Kind"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Msgs"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Tokens"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Dropped before end"}),e.jsx("th",{className:"px-3 py-2 text-right text-[10px] uppercase tracking-wider text-gray-500 font-medium",children:"Cost"})]})}),e.jsx("tbody",{children:r.abandoned_branches.map(n=>e.jsx(z,{branch:n,currency:s},`${n.session_id}:${n.branch_head_uuid}`))})]})})]})]})}export{A as default}; diff --git a/stackunderflow/static/react/assets/IconActivity-BkaNd4nH.js b/stackunderflow/static/react/assets/IconActivity-Bf5MQINx.js similarity index 86% rename from stackunderflow/static/react/assets/IconActivity-BkaNd4nH.js rename to stackunderflow/static/react/assets/IconActivity-Bf5MQINx.js index e5ae715..48b7ed1 100644 --- a/stackunderflow/static/react/assets/IconActivity-BkaNd4nH.js +++ b/stackunderflow/static/react/assets/IconActivity-Bf5MQINx.js @@ -1,4 +1,4 @@ -import{h as t}from"./index-DDMGE_ZF.js";/** +import{h as t}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconAlertCircle-DyBnJSK0.js b/stackunderflow/static/react/assets/IconAlertCircle-h2zR9MWj.js similarity index 89% rename from stackunderflow/static/react/assets/IconAlertCircle-DyBnJSK0.js rename to stackunderflow/static/react/assets/IconAlertCircle-h2zR9MWj.js index 430c462..3546d5d 100644 --- a/stackunderflow/static/react/assets/IconAlertCircle-DyBnJSK0.js +++ b/stackunderflow/static/react/assets/IconAlertCircle-h2zR9MWj.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconArrowRight-xIaEMpRY.js b/stackunderflow/static/react/assets/IconArrowRight-lOOgkPXu.js similarity index 89% rename from stackunderflow/static/react/assets/IconArrowRight-xIaEMpRY.js rename to stackunderflow/static/react/assets/IconArrowRight-lOOgkPXu.js index ace5a3a..d4e5f5b 100644 --- a/stackunderflow/static/react/assets/IconArrowRight-xIaEMpRY.js +++ b/stackunderflow/static/react/assets/IconArrowRight-lOOgkPXu.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconArrowUp-CszCUTPq.js b/stackunderflow/static/react/assets/IconArrowUp-D8NJ3QQF.js similarity index 94% rename from stackunderflow/static/react/assets/IconArrowUp-CszCUTPq.js rename to stackunderflow/static/react/assets/IconArrowUp-D8NJ3QQF.js index b78c6b0..dd700cc 100644 --- a/stackunderflow/static/react/assets/IconArrowUp-CszCUTPq.js +++ b/stackunderflow/static/react/assets/IconArrowUp-D8NJ3QQF.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconBolt-CWu2Wz0a.js b/stackunderflow/static/react/assets/IconBolt-CWu2Wz0a.js new file mode 100644 index 0000000..d96faf9 --- /dev/null +++ b/stackunderflow/static/react/assets/IconBolt-CWu2Wz0a.js @@ -0,0 +1,6 @@ +import{h as o}from"./index-DQluCO2S.js";/** + * @license @tabler/icons-react v3.36.1 - MIT + * + * This source code is licensed under the MIT license. + * See the LICENSE file in the root directory of this source tree. + */const t=[["path",{d:"M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11",key:"svg-0"}]],e=o("outline","bolt","Bolt",t);export{e as I}; diff --git a/stackunderflow/static/react/assets/IconCheck-DwMBTi_m.js b/stackunderflow/static/react/assets/IconCheck-BQLu3HFV.js similarity index 86% rename from stackunderflow/static/react/assets/IconCheck-DwMBTi_m.js rename to stackunderflow/static/react/assets/IconCheck-BQLu3HFV.js index 3b3dfeb..3aa2499 100644 --- a/stackunderflow/static/react/assets/IconCheck-DwMBTi_m.js +++ b/stackunderflow/static/react/assets/IconCheck-BQLu3HFV.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconCircleX-1-WYCT5P.js b/stackunderflow/static/react/assets/IconCircleX-D7ABaoUn.js similarity index 88% rename from stackunderflow/static/react/assets/IconCircleX-1-WYCT5P.js rename to stackunderflow/static/react/assets/IconCircleX-D7ABaoUn.js index 6951d9a..7a4316f 100644 --- a/stackunderflow/static/react/assets/IconCircleX-1-WYCT5P.js +++ b/stackunderflow/static/react/assets/IconCircleX-D7ABaoUn.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconClock-Dl-xdEIm.js b/stackunderflow/static/react/assets/IconClock-CgB7iTL4.js similarity index 88% rename from stackunderflow/static/react/assets/IconClock-Dl-xdEIm.js rename to stackunderflow/static/react/assets/IconClock-CgB7iTL4.js index 5b1dab4..05f5e65 100644 --- a/stackunderflow/static/react/assets/IconClock-Dl-xdEIm.js +++ b/stackunderflow/static/react/assets/IconClock-CgB7iTL4.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconClockHour4-UHU_zj__.js b/stackunderflow/static/react/assets/IconClockHour4-BUN27e3Z.js similarity index 89% rename from stackunderflow/static/react/assets/IconClockHour4-UHU_zj__.js rename to stackunderflow/static/react/assets/IconClockHour4-BUN27e3Z.js index 38dd699..f4d7367 100644 --- a/stackunderflow/static/react/assets/IconClockHour4-UHU_zj__.js +++ b/stackunderflow/static/react/assets/IconClockHour4-BUN27e3Z.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconCode-DaQ_eMLh.js b/stackunderflow/static/react/assets/IconCode-nzK0btv1.js similarity index 88% rename from stackunderflow/static/react/assets/IconCode-DaQ_eMLh.js rename to stackunderflow/static/react/assets/IconCode-nzK0btv1.js index 9ed92f8..35c3536 100644 --- a/stackunderflow/static/react/assets/IconCode-DaQ_eMLh.js +++ b/stackunderflow/static/react/assets/IconCode-nzK0btv1.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconCopy-DA8lvnPB.js b/stackunderflow/static/react/assets/IconCopy-BOYWZx4M.js similarity index 92% rename from stackunderflow/static/react/assets/IconCopy-DA8lvnPB.js rename to stackunderflow/static/react/assets/IconCopy-BOYWZx4M.js index cda992b..fadf25c 100644 --- a/stackunderflow/static/react/assets/IconCopy-DA8lvnPB.js +++ b/stackunderflow/static/react/assets/IconCopy-BOYWZx4M.js @@ -1,4 +1,4 @@ -import{h as o}from"./index-DDMGE_ZF.js";/** +import{h as o}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconFileText-C9y_jNvO.js b/stackunderflow/static/react/assets/IconFileText-ChD_eRqq.js similarity index 91% rename from stackunderflow/static/react/assets/IconFileText-C9y_jNvO.js rename to stackunderflow/static/react/assets/IconFileText-ChD_eRqq.js index 2a31a76..6c61a93 100644 --- a/stackunderflow/static/react/assets/IconFileText-C9y_jNvO.js +++ b/stackunderflow/static/react/assets/IconFileText-ChD_eRqq.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconHash-Bsu2htWI.js b/stackunderflow/static/react/assets/IconHash-cdeuBxnj.js similarity index 89% rename from stackunderflow/static/react/assets/IconHash-Bsu2htWI.js rename to stackunderflow/static/react/assets/IconHash-cdeuBxnj.js index 4645bee..a43146d 100644 --- a/stackunderflow/static/react/assets/IconHash-Bsu2htWI.js +++ b/stackunderflow/static/react/assets/IconHash-cdeuBxnj.js @@ -1,4 +1,4 @@ -import{h as t}from"./index-DDMGE_ZF.js";/** +import{h as t}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DO-WPkWS.js b/stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DA8tH9T1.js similarity index 95% rename from stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DO-WPkWS.js rename to stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DA8tH9T1.js index f62e60d..d455075 100644 --- a/stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DO-WPkWS.js +++ b/stackunderflow/static/react/assets/IconPlayerSkipForwardFilled-DA8tH9T1.js @@ -1,4 +1,4 @@ -import{h as a}from"./index-DDMGE_ZF.js";/** +import{h as a}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconRobot-BQwymFKA.js b/stackunderflow/static/react/assets/IconRobot-DuCKb11z.js similarity index 93% rename from stackunderflow/static/react/assets/IconRobot-BQwymFKA.js rename to stackunderflow/static/react/assets/IconRobot-DuCKb11z.js index 7cb2c54..9da9179 100644 --- a/stackunderflow/static/react/assets/IconRobot-BQwymFKA.js +++ b/stackunderflow/static/react/assets/IconRobot-DuCKb11z.js @@ -1,4 +1,4 @@ -import{h as t}from"./index-DDMGE_ZF.js";/** +import{h as t}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconSortDescending-nc0XizMI.js b/stackunderflow/static/react/assets/IconSortDescending-DunfClYo.js similarity index 90% rename from stackunderflow/static/react/assets/IconSortDescending-nc0XizMI.js rename to stackunderflow/static/react/assets/IconSortDescending-DunfClYo.js index 979bf23..5b788d4 100644 --- a/stackunderflow/static/react/assets/IconSortDescending-nc0XizMI.js +++ b/stackunderflow/static/react/assets/IconSortDescending-DunfClYo.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconTrendingUp-DvwC6rkL.js b/stackunderflow/static/react/assets/IconTrendingUp-D1iuYAG9.js similarity index 88% rename from stackunderflow/static/react/assets/IconTrendingUp-DvwC6rkL.js rename to stackunderflow/static/react/assets/IconTrendingUp-D1iuYAG9.js index 25434fe..9d21fde 100644 --- a/stackunderflow/static/react/assets/IconTrendingUp-DvwC6rkL.js +++ b/stackunderflow/static/react/assets/IconTrendingUp-D1iuYAG9.js @@ -1,4 +1,4 @@ -import{h as n}from"./index-DDMGE_ZF.js";/** +import{h as n}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/IconUser-ectHbzBi.js b/stackunderflow/static/react/assets/IconUser-IjRNaThB.js similarity index 94% rename from stackunderflow/static/react/assets/IconUser-ectHbzBi.js rename to stackunderflow/static/react/assets/IconUser-IjRNaThB.js index 341b896..79f2813 100644 --- a/stackunderflow/static/react/assets/IconUser-ectHbzBi.js +++ b/stackunderflow/static/react/assets/IconUser-IjRNaThB.js @@ -1,4 +1,4 @@ -import{h as e}from"./index-DDMGE_ZF.js";/** +import{h as e}from"./index-DQluCO2S.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/Live-B7kSsFN1.js b/stackunderflow/static/react/assets/Live-B7kSsFN1.js deleted file mode 100644 index e074d3f..0000000 --- a/stackunderflow/static/react/assets/Live-B7kSsFN1.js +++ /dev/null @@ -1,6 +0,0 @@ -import{r as j,u as E,j as e}from"./react-vendor-B7v2HPaI.js";import{b as u,c as L}from"./format-Co_unrac.js";import{h as $,u as C,L as T,p as S}from"./index-DDMGE_ZF.js";import{E as I}from"./EmptyState-o0gibvhZ.js";import{I as R}from"./IconClockHour4-UHU_zj__.js";import{I as w}from"./IconActivity-BkaNd4nH.js";/** - * @license @tabler/icons-react v3.36.1 - MIT - * - * This source code is licensed under the MIT license. - * See the LICENSE file in the root directory of this source tree. - */const F=[["path",{d:"M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11",key:"svg-0"}]],P=$("outline","bolt","Bolt",F),_="/api";async function B(){const t=await fetch(`${_}/live/stats`);if(!t.ok){const n=await t.text().catch(()=>"");throw new Error(`${t.status} ${t.statusText}${n?`: ${n}`:""}`)}return t.json()}function z(t,n=`${_}/live/stream`){const o=new EventSource(n),a=(r,c,d)=>{if(!r)return;let i={};try{i=JSON.parse(c.data)}catch{return}i.payload!==void 0&&(d?r(i.payload,i.ts??""):r(i.payload))};o.addEventListener("ready",r=>a(t.onReady,r,!1)),o.addEventListener("event",r=>a(t.onEvent,r,!0)),o.addEventListener("tool_call",r=>a(t.onToolCall,r,!0)),o.addEventListener("burn_tick",r=>a(t.onBurnTick,r,!0)),t.onError&&o.addEventListener("error",t.onError);let l=!1;return{source:o,close:()=>{l||(l=!0,o.close())}}}const N=100,k={connected:!1,events:[],toolCalls:[],burn:null,watcher:null,errorCount:0};function A(t=!0){const[n,o]=j.useState(k),a=j.useRef(k);return j.useEffect(()=>{if(!t||typeof EventSource>"u")return;const l=z({onReady:r=>{o(c=>{const d={...c,connected:!0,watcher:r.watcher};return a.current=d,d})},onEvent:r=>{o(c=>{const d=[r,...c.events].slice(0,N),i={...c,events:d};return a.current=i,i})},onToolCall:r=>{o(c=>{const d=[r,...c.toolCalls].slice(0,N),i={...c,toolCalls:d};return a.current=i,i})},onBurnTick:r=>{o(c=>{const d={...c,burn:r};return a.current=d,d})},onError:()=>{o(r=>{const c={...r,errorCount:r.errorCount+1};return a.current=c,c})}});return()=>l.close()},[t]),n}function b(t){return!Number.isFinite(t)||t<0?"-":t<1?`${(t*1e3).toFixed(0)}ms`:t<60?`${t.toFixed(1)}s`:t<3600?`${(t/60).toFixed(1)}m`:`${(t/3600).toFixed(1)}h`}function M(t){const n=new Date(t);return Number.isNaN(n.getTime())?t:n.toLocaleTimeString(void 0,{hour12:!1})}function J(){var v;const{currency:t}=C(),{data:n,isLoading:o}=E({queryKey:["liveStats"],queryFn:B,refetchInterval:3e4,refetchOnWindowFocus:!0}),a=A(!0),l=a.burn??(n==null?void 0:n.burn)??null,r=(n==null?void 0:n.tool_latency)??[],d=(((v=a.watcher)==null?void 0:v.running)??(n==null?void 0:n.watcher.running)??"unknown")!==!0,i=j.useMemo(()=>{const{events:s,toolCalls:f}=a,m=[];let g=0,h=0;for(;m.length<100&&(g=x.ts))m.push({kind:"event",ts:p.ts,row:p}),g++;else if(x)m.push({kind:"tool_call",ts:x.ts,row:x}),h++;else break}return m},[a.events,a.toolCalls]);return o&&!a.connected?e.jsx(T,{message:"Loading live snapshot..."}):e.jsxs("div",{className:"max-w-7xl mx-auto p-6 space-y-6",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsxs("div",{children:[e.jsx("h1",{className:"text-xl font-bold text-gray-900 dark:text-gray-100",children:"Live"}),e.jsxs("p",{className:"text-sm text-gray-500 mt-0.5",children:["Real-time across all active sessions.",a.connected?e.jsxs("span",{className:"ml-2 inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400",children:[e.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"}),"streaming"]}):e.jsx("span",{className:"ml-2 text-gray-500",children:"connecting…"})]})]})}),d&&e.jsxs("div",{role:"alert","data-testid":"watcher-down-banner",className:"flex items-start gap-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-4 text-yellow-800 dark:text-yellow-300",children:[e.jsx(S,{size:20,className:"shrink-0 mt-0.5"}),e.jsxs("div",{className:"text-sm space-y-1",children:[e.jsx("p",{className:"font-medium",children:"Filesystem watcher is not running."}),e.jsxs("p",{children:["The live stream depends on the watcher to ingest new sessions sub-second. Restart the server without ",e.jsx("code",{className:"px-1 py-0.5 bg-yellow-100 dark:bg-yellow-900/40 rounded text-xs",children:"--no-watcher"})," to resume real-time updates. Burn rate and tool latency below reflect data already in the store."]})]})]}),e.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-4 gap-4",children:[e.jsx(y,{label:"Per minute",value:l?u(l.per_minute,t):"-",icon:e.jsx(P,{size:18}),subtle:l?`${l.window_minutes}-min rolling avg`:""}),e.jsx(y,{label:"Per hour",value:l?u(l.per_hour,t):"-",icon:e.jsx(R,{size:18}),subtle:l?`${u(l.window_cost,t)} in window`:""}),e.jsx(y,{label:"Today",value:l?u(l.today_cost,t):"-",icon:e.jsx(w,{size:18}),subtle:l?`MTD ${u(l.month_to_date,t)}`:""}),e.jsx(y,{label:"Projected month-end",value:l?u(l.projected_month_end,t):"-",icon:e.jsx(w,{size:18}),subtle:"straight-line extrapolation"})]}),e.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-3 gap-6",children:[e.jsxs("div",{className:"lg:col-span-2 bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between mb-3",children:[e.jsx("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:"Event stream"}),e.jsxs("span",{className:"text-xs text-gray-500",children:[i.length," of last 100"]})]}),e.jsx("div",{className:"space-y-1 overflow-y-auto max-h-[500px] font-mono text-xs","data-testid":"live-event-stream",children:i.length===0?e.jsx(I,{title:"Waiting for activity",description:d?"Watcher is not running — start it to see live events.":"Run a Claude / Codex session and events will land here in real time."}):i.map(s=>e.jsxs("div",{className:"flex items-baseline gap-3 py-1 border-b border-gray-200/50 dark:border-gray-800/50",children:[e.jsx("span",{className:"text-gray-500 tabular-nums w-20 shrink-0",children:M(s.ts)}),e.jsx("span",{className:`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide shrink-0 ${s.kind==="tool_call"?"bg-indigo-500/15 text-indigo-700 dark:text-indigo-300":"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"}`,children:s.kind==="tool_call"?s.row.tool_name:"event"}),e.jsx("span",{className:"text-gray-700 dark:text-gray-300 truncate",children:s.kind==="tool_call"?e.jsxs(e.Fragment,{children:[s.row.project_name??s.row.project_slug??`project ${s.row.project_id}`,s.row.file_path?e.jsxs("span",{className:"text-gray-500",children:[" · ",s.row.file_path]}):null,s.row.byte_count!=null?e.jsxs("span",{className:"text-gray-500",children:[" · ",L(s.row.byte_count),"b"]}):null]}):e.jsxs(e.Fragment,{children:[s.row.project_name??s.row.project_slug??`project ${s.row.project_id}`,e.jsxs("span",{className:"text-gray-500",children:[" · ",s.row.model]}),e.jsxs("span",{className:"text-gray-500",children:[" · ",u(s.row.cost_usd,t)]})]})})]},`${s.kind}-${s.row.id}`))})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:"Tool latency (P50 / P95 / P99)"}),r.length===0?e.jsx("p",{className:"text-xs text-gray-500",children:"No tool-call samples in the last 24h."}):e.jsx("div",{className:"space-y-2",children:r.map(s=>e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs","data-testid":"latency-row",children:[e.jsx("span",{className:"font-medium text-gray-700 dark:text-gray-300 truncate",children:s.tool_name}),e.jsxs("div",{className:"flex items-baseline gap-3 tabular-nums shrink-0",children:[e.jsxs("span",{className:"text-gray-500",title:`${s.samples} samples`,children:[s.samples,"n"]}),e.jsx("span",{className:"text-gray-700 dark:text-gray-300",title:"P50",children:b(s.p50)}),e.jsx("span",{className:"text-indigo-600 dark:text-indigo-400",title:"P95",children:b(s.p95)}),e.jsx("span",{className:"text-rose-600 dark:text-rose-400",title:"P99",children:b(s.p99)})]})]},s.tool_name))}),e.jsxs("p",{className:"mt-3 text-[10px] text-gray-500 leading-snug",children:["Latency derived from ",e.jsx("code",{children:"messages.timestamp"})," deltas between a tool_use and the next message in the same session — coarse, only as fine as the source-file write cadence."]})]})]})]})}function y({label:t,value:n,icon:o,subtle:a}){return e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between text-xs text-gray-500 uppercase tracking-wider",children:[e.jsx("span",{children:t}),e.jsx("span",{className:"text-gray-400 dark:text-gray-600",children:o})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1 tabular-nums",children:n}),a&&e.jsx("div",{className:"text-[10px] text-gray-500 mt-0.5",children:a})]})}export{J as default}; diff --git a/stackunderflow/static/react/assets/Live-BX6jEGZn.js b/stackunderflow/static/react/assets/Live-BX6jEGZn.js new file mode 100644 index 0000000..8aef493 --- /dev/null +++ b/stackunderflow/static/react/assets/Live-BX6jEGZn.js @@ -0,0 +1 @@ +import{r as f,u as E,j as e}from"./react-vendor-B7v2HPaI.js";import{b as u,c as L}from"./format-Co_unrac.js";import{u as $,L as C,p as T}from"./index-DQluCO2S.js";import{E as S}from"./EmptyState-o0gibvhZ.js";import{I}from"./IconBolt-CWu2Wz0a.js";import{I as F}from"./IconClockHour4-BUN27e3Z.js";import{I as v}from"./IconActivity-Bf5MQINx.js";const _="/api";async function R(){const t=await fetch(`${_}/live/stats`);if(!t.ok){const a=await t.text().catch(()=>"");throw new Error(`${t.status} ${t.statusText}${a?`: ${a}`:""}`)}return t.json()}function P(t,a=`${_}/live/stream`){const o=new EventSource(a),n=(s,i,d)=>{if(!s)return;let c={};try{c=JSON.parse(i.data)}catch{return}c.payload!==void 0&&(d?s(c.payload,c.ts??""):s(c.payload))};o.addEventListener("ready",s=>n(t.onReady,s,!1)),o.addEventListener("event",s=>n(t.onEvent,s,!0)),o.addEventListener("tool_call",s=>n(t.onToolCall,s,!0)),o.addEventListener("burn_tick",s=>n(t.onBurnTick,s,!0)),t.onError&&o.addEventListener("error",t.onError);let l=!1;return{source:o,close:()=>{l||(l=!0,o.close())}}}const N=100,k={connected:!1,events:[],toolCalls:[],burn:null,watcher:null,errorCount:0};function B(t=!0){const[a,o]=f.useState(k),n=f.useRef(k);return f.useEffect(()=>{if(!t||typeof EventSource>"u")return;const l=P({onReady:s=>{o(i=>{const d={...i,connected:!0,watcher:s.watcher};return n.current=d,d})},onEvent:s=>{o(i=>{const d=[s,...i.events].slice(0,N),c={...i,events:d};return n.current=c,c})},onToolCall:s=>{o(i=>{const d=[s,...i.toolCalls].slice(0,N),c={...i,toolCalls:d};return n.current=c,c})},onBurnTick:s=>{o(i=>{const d={...i,burn:s};return n.current=d,d})},onError:()=>{o(s=>{const i={...s,errorCount:s.errorCount+1};return n.current=i,i})}});return()=>l.close()},[t]),a}function b(t){return!Number.isFinite(t)||t<0?"-":t<1?`${(t*1e3).toFixed(0)}ms`:t<60?`${t.toFixed(1)}s`:t<3600?`${(t/60).toFixed(1)}m`:`${(t/3600).toFixed(1)}h`}function z(t){const a=new Date(t);return Number.isNaN(a.getTime())?t:a.toLocaleTimeString(void 0,{hour12:!1})}function H(){var w;const{currency:t}=$(),{data:a,isLoading:o}=E({queryKey:["liveStats"],queryFn:R,refetchInterval:3e4,refetchOnWindowFocus:!0}),n=B(!0),l=n.burn??(a==null?void 0:a.burn)??null,s=(a==null?void 0:a.tool_latency)??[],d=(((w=n.watcher)==null?void 0:w.running)??(a==null?void 0:a.watcher.running)??"unknown")!==!0,c=f.useMemo(()=>{const{events:r,toolCalls:j}=n,m=[];let g=0,h=0;for(;m.length<100&&(g=x.ts))m.push({kind:"event",ts:p.ts,row:p}),g++;else if(x)m.push({kind:"tool_call",ts:x.ts,row:x}),h++;else break}return m},[n.events,n.toolCalls]);return o&&!n.connected?e.jsx(C,{message:"Loading live snapshot..."}):e.jsxs("div",{className:"max-w-7xl mx-auto p-6 space-y-6",children:[e.jsx("div",{className:"flex items-center justify-between",children:e.jsxs("div",{children:[e.jsx("h1",{className:"text-xl font-bold text-gray-900 dark:text-gray-100",children:"Live"}),e.jsxs("p",{className:"text-sm text-gray-500 mt-0.5",children:["Real-time across all active sessions.",n.connected?e.jsxs("span",{className:"ml-2 inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400",children:[e.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"}),"streaming"]}):e.jsx("span",{className:"ml-2 text-gray-500",children:"connecting…"})]})]})}),d&&e.jsxs("div",{role:"alert","data-testid":"watcher-down-banner",className:"flex items-start gap-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-4 text-yellow-800 dark:text-yellow-300",children:[e.jsx(T,{size:20,className:"shrink-0 mt-0.5"}),e.jsxs("div",{className:"text-sm space-y-1",children:[e.jsx("p",{className:"font-medium",children:"Filesystem watcher is not running."}),e.jsxs("p",{children:["The live stream depends on the watcher to ingest new sessions sub-second. Restart the server without ",e.jsx("code",{className:"px-1 py-0.5 bg-yellow-100 dark:bg-yellow-900/40 rounded text-xs",children:"--no-watcher"})," to resume real-time updates. Burn rate and tool latency below reflect data already in the store."]})]})]}),e.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-4 gap-4",children:[e.jsx(y,{label:"Per minute",value:l?u(l.per_minute,t):"-",icon:e.jsx(I,{size:18}),subtle:l?`${l.window_minutes}-min rolling avg`:""}),e.jsx(y,{label:"Per hour",value:l?u(l.per_hour,t):"-",icon:e.jsx(F,{size:18}),subtle:l?`${u(l.window_cost,t)} in window`:""}),e.jsx(y,{label:"Today",value:l?u(l.today_cost,t):"-",icon:e.jsx(v,{size:18}),subtle:l?`MTD ${u(l.month_to_date,t)}`:""}),e.jsx(y,{label:"Projected month-end",value:l?u(l.projected_month_end,t):"-",icon:e.jsx(v,{size:18}),subtle:"straight-line extrapolation"})]}),e.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-3 gap-6",children:[e.jsxs("div",{className:"lg:col-span-2 bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between mb-3",children:[e.jsx("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:"Event stream"}),e.jsxs("span",{className:"text-xs text-gray-500",children:[c.length," of last 100"]})]}),e.jsx("div",{className:"space-y-1 overflow-y-auto max-h-[500px] font-mono text-xs","data-testid":"live-event-stream",children:c.length===0?e.jsx(S,{title:"Waiting for activity",description:d?"Watcher is not running — start it to see live events.":"Run a Claude / Codex session and events will land here in real time."}):c.map(r=>e.jsxs("div",{className:"flex items-baseline gap-3 py-1 border-b border-gray-200/50 dark:border-gray-800/50",children:[e.jsx("span",{className:"text-gray-500 tabular-nums w-20 shrink-0",children:z(r.ts)}),e.jsx("span",{className:`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide shrink-0 ${r.kind==="tool_call"?"bg-indigo-500/15 text-indigo-700 dark:text-indigo-300":"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"}`,children:r.kind==="tool_call"?r.row.tool_name:"event"}),e.jsx("span",{className:"text-gray-700 dark:text-gray-300 truncate",children:r.kind==="tool_call"?e.jsxs(e.Fragment,{children:[r.row.project_name??r.row.project_slug??`project ${r.row.project_id}`,r.row.file_path?e.jsxs("span",{className:"text-gray-500",children:[" · ",r.row.file_path]}):null,r.row.byte_count!=null?e.jsxs("span",{className:"text-gray-500",children:[" · ",L(r.row.byte_count),"b"]}):null]}):e.jsxs(e.Fragment,{children:[r.row.project_name??r.row.project_slug??`project ${r.row.project_id}`,e.jsxs("span",{className:"text-gray-500",children:[" · ",r.row.model]}),e.jsxs("span",{className:"text-gray-500",children:[" · ",u(r.row.cost_usd,t)]})]})})]},`${r.kind}-${r.row.id}`))})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:"Tool latency (P50 / P95 / P99)"}),s.length===0?e.jsx("p",{className:"text-xs text-gray-500",children:"No tool-call samples in the last 24h."}):e.jsx("div",{className:"space-y-2",children:s.map(r=>e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs","data-testid":"latency-row",children:[e.jsx("span",{className:"font-medium text-gray-700 dark:text-gray-300 truncate",children:r.tool_name}),e.jsxs("div",{className:"flex items-baseline gap-3 tabular-nums shrink-0",children:[e.jsxs("span",{className:"text-gray-500",title:`${r.samples} samples`,children:[r.samples,"n"]}),e.jsx("span",{className:"text-gray-700 dark:text-gray-300",title:"P50",children:b(r.p50)}),e.jsx("span",{className:"text-indigo-600 dark:text-indigo-400",title:"P95",children:b(r.p95)}),e.jsx("span",{className:"text-rose-600 dark:text-rose-400",title:"P99",children:b(r.p99)})]})]},r.tool_name))}),e.jsxs("p",{className:"mt-3 text-[10px] text-gray-500 leading-snug",children:["Latency derived from ",e.jsx("code",{children:"messages.timestamp"})," deltas between a tool_use and the next message in the same session — coarse, only as fine as the source-file write cadence."]})]})]})]})}function y({label:t,value:a,icon:o,subtle:n}){return e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between text-xs text-gray-500 uppercase tracking-wider",children:[e.jsx("span",{children:t}),e.jsx("span",{className:"text-gray-400 dark:text-gray-600",children:o})]}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1 tabular-nums",children:a}),n&&e.jsx("div",{className:"text-[10px] text-gray-500 mt-0.5",children:n})]})}export{H as default}; diff --git a/stackunderflow/static/react/assets/MessagesTab-Dcm_kMZs.js b/stackunderflow/static/react/assets/MessagesTab-Dull7JZB.js similarity index 96% rename from stackunderflow/static/react/assets/MessagesTab-Dcm_kMZs.js rename to stackunderflow/static/react/assets/MessagesTab-Dull7JZB.js index 8258cd8..b24a8b0 100644 --- a/stackunderflow/static/react/assets/MessagesTab-Dcm_kMZs.js +++ b/stackunderflow/static/react/assets/MessagesTab-Dull7JZB.js @@ -1,4 +1,4 @@ -import{r as o,u as Q,j as e,n as J}from"./react-vendor-B7v2HPaI.js";import{D as W}from"./DataTable-D0JcWuTA.js";import{a as ee,B as y,S as D,T as se,j as te,c as re,U as ae}from"./index-DDMGE_ZF.js";import{M as ne}from"./Modal-Da9xsK-A.js";import oe from"./Markdown-Be0CkqQs.js";import{P as K}from"./ProviderChip-PiRoWNpI.js";import{g as ie,N as q}from"./ProjectDashboard-DRHQbkve.js";import{f as N}from"./format-Co_unrac.js";import{I as le}from"./FilterBar-DuduzIn6.js";import"./IconArrowUp-CszCUTPq.js";import"./syntax-highlighter-BYF-y4SB.js";import"./markdown-DgJRk3H5.js";import"./EmptyState-o0gibvhZ.js";import"./dashboardTabs-CJWioGLA.js";const de={user:"blue",assistant:"green",tool_use:"purple",tool_result:"yellow",system:"gray",error:"red"};function P(t){return de[t]??"gray"}function v(t){try{return new Date(t).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return t}}function F(t){return t.length<=12?t:t.slice(0,8)+"..."}function ce(t){return t.error===!0||t.type==="error"}function h(t){return t.includes(",")||t.includes('"')||t.includes(` +import{r as o,u as Q,j as e,n as J}from"./react-vendor-B7v2HPaI.js";import{D as W}from"./DataTable-DCEy-2Es.js";import{a as ee,B as y,S as D,T as se,j as te,c as re,U as ae}from"./index-DQluCO2S.js";import{M as ne}from"./Modal-DkeyqO4J.js";import oe from"./Markdown-Be0CkqQs.js";import{P as K}from"./ProviderChip-baymKnS_.js";import{g as ie,N as q}from"./ProjectDashboard-DAsn11K-.js";import{f as N}from"./format-Co_unrac.js";import{I as le}from"./FilterBar-BS6lhcC1.js";import"./IconArrowUp-D8NJ3QQF.js";import"./syntax-highlighter-BYF-y4SB.js";import"./markdown-DgJRk3H5.js";import"./EmptyState-o0gibvhZ.js";import"./dashboardTabs-C5ecR5YO.js";const de={user:"blue",assistant:"green",tool_use:"purple",tool_result:"yellow",system:"gray",error:"red"};function P(t){return de[t]??"gray"}function v(t){try{return new Date(t).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch{return t}}function F(t){return t.length<=12?t:t.slice(0,8)+"..."}function ce(t){return t.error===!0||t.type==="error"}function h(t){return t.includes(",")||t.includes('"')||t.includes(` `)?`"${t.replace(/"/g,'""')}"`:t}function xe(t,n){for(const l of t)if(l.message_id===n||l.uuid===n)return l;return null}async function pe(t){const n=await fetch(`/api/interaction/${encodeURIComponent(t)}`);if(!n.ok){const l=await n.text().catch(()=>"");throw new Error(`${n.status} ${n.statusText}${l?`: ${l}`:""}`)}return n.json()}function Pe({data:t,projectName:n}){var A,U,B;const[l,_]=o.useState("all"),[f,G]=o.useState(!1),[r,L]=o.useState(null),[x,j]=o.useState(null),[H,I]=o.useState(!1),R=o.useRef(null),m=o.useRef(null),C=(U=(A=Q({queryKey:["projectsList"],queryFn:()=>re(!1),staleTime:6e4}).data)==null?void 0:A.projects.find(s=>s.dir_name===n||s.url_slug===n))==null?void 0:U.provider,z=((B=t.messages_page)==null?void 0:B.messages)??[],{filters:g}=ee(),V=500,[b,S]=o.useState(1);o.useEffect(()=>{S(1)},[g.providers,g.models]);const{data:d,isLoading:T,error:M}=Q({queryKey:["messagesPage",n,b,V,g.providers,g.models],queryFn:()=>ae({page:b,perPage:V},{providers:g.providers,models:g.models}),placeholderData:J}),p=(d==null?void 0:d.messages)??z,$=(d==null?void 0:d.total)??z.length,k=(d==null?void 0:d.total_pages)??1,O=o.useMemo(()=>{let s=p;return l!=="all"&&(s=s.filter(a=>a.type===l)),f&&(s=s.filter(ce)),s},[p,l,f]),E=o.useCallback(async s=>{if(!s)return;const a=xe(p,s);if(a)j({kind:"loaded",interactionId:s,message:a});else{j({kind:"loading",interactionId:s});try{const i=await pe(s);j({kind:"fetched",interactionId:s,data:i})}catch(i){j({kind:"error",interactionId:s,message:i instanceof Error?i.message:"Unknown error"})}}m.current!==null&&window.clearTimeout(m.current),I(!0),m.current=window.setTimeout(()=>{I(!1),m.current=null},2e3)},[p]);o.useEffect(()=>{const s=ie("interaction");s&&E(s)},[d]),o.useEffect(()=>{function s(a){const c=a.detail;if(!c||c.tab!=="messages")return;const u=c.interaction;u&&E(u)}return window.addEventListener(q,s),()=>window.removeEventListener(q,s)},[E]),o.useEffect(()=>{var s;x&&((s=R.current)==null||s.scrollIntoView({behavior:"smooth",block:"start"}))},[x==null?void 0:x.interactionId,x==null?void 0:x.kind]),o.useEffect(()=>()=>{m.current!==null&&window.clearTimeout(m.current)},[]);const X=o.useMemo(()=>[{key:"type",label:"Type",width:"100px",render:s=>e.jsx(y,{color:P(s.type),size:"sm",children:s.type}),sortValue:s=>s.type},{key:"content",label:"Content",render:s=>e.jsx("span",{className:"text-gray-700 dark:text-gray-300 text-xs whitespace-pre-wrap break-words block",children:s.content.length>300?s.content.slice(0,300)+"…":s.content})},{key:"timestamp",label:"Timestamp",width:"160px",render:s=>e.jsx("span",{className:"text-gray-600 dark:text-gray-400 text-xs whitespace-nowrap",children:v(s.timestamp)}),sortValue:s=>new Date(s.timestamp).getTime()},{key:"model",label:"Model",width:"200px",render:s=>s.model?e.jsxs("span",{className:"inline-flex items-center gap-1.5 min-w-0",children:[e.jsx(K,{provider:C}),e.jsx("span",{className:"text-gray-600 dark:text-gray-400 text-xs truncate",title:s.model,children:N(s.model)})]}):e.jsx("span",{className:"text-gray-500 text-xs",children:"-"})},{key:"session",label:"Session",width:"100px",render:s=>e.jsx("span",{className:"text-gray-500 text-xs font-mono",title:s.session_id,children:F(s.session_id)})},{key:"tools",label:"Tools",width:"160px",render:s=>s.tools&&s.tools.length>0?e.jsx("div",{className:"flex flex-wrap gap-1",children:s.tools.map(a=>{const i=typeof a=="string"?a:a.name;return e.jsx(y,{color:"purple",size:"sm",children:i},i)})}):e.jsx("span",{className:"text-gray-500 text-xs",children:"-"})}],[C]),Y=s=>{L(s)},Z=s=>{const i=[["Type","Content","Timestamp","Model","Session","Tools"].join(",")];for(const c of s)i.push([h(c.type),h(c.content),h(c.timestamp),h(c.model??""),h(c.session_id),h((c.tools??[]).map(u=>typeof u=="string"?u:u.name).join("; "))].join(","));return i.join(` `)},w=o.useMemo(()=>{const s={};for(const a of p)s[a.type]=(s[a.type]??0)+1;return s},[p]);return e.jsxs("div",{className:"space-y-3",children:[e.jsx("style",{children:` @keyframes su-msg-pulse { diff --git a/stackunderflow/static/react/assets/Modal-Da9xsK-A.js b/stackunderflow/static/react/assets/Modal-DkeyqO4J.js similarity index 94% rename from stackunderflow/static/react/assets/Modal-Da9xsK-A.js rename to stackunderflow/static/react/assets/Modal-DkeyqO4J.js index f4bba01..1a21660 100644 --- a/stackunderflow/static/react/assets/Modal-Da9xsK-A.js +++ b/stackunderflow/static/react/assets/Modal-DkeyqO4J.js @@ -1 +1 @@ -import{r as s,j as e}from"./react-vendor-B7v2HPaI.js";import{I as i}from"./FilterBar-DuduzIn6.js";function x({isOpen:a,onClose:r,title:d,children:o}){const t=s.useCallback(l=>{l.key==="Escape"&&r()},[r]);return s.useEffect(()=>{if(a)return document.addEventListener("keydown",t),()=>document.removeEventListener("keydown",t)},[a,t]),a?e.jsxs("div",{className:"fixed inset-0 z-50 flex items-center justify-center",children:[e.jsx("div",{className:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:r}),e.jsxs("div",{className:"relative z-10 w-full max-w-lg mx-4 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl",children:[e.jsxs("div",{className:"flex items-center justify-between px-4 py-3 border-b border-gray-300 dark:border-gray-700",children:[e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:d}),e.jsx("button",{onClick:r,className:"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700","aria-label":"Close modal",children:e.jsx(i,{size:16})})]}),e.jsx("div",{className:"p-4",children:o})]})]}):null}export{x as M}; +import{r as s,j as e}from"./react-vendor-B7v2HPaI.js";import{I as i}from"./FilterBar-BS6lhcC1.js";function x({isOpen:a,onClose:r,title:d,children:o}){const t=s.useCallback(l=>{l.key==="Escape"&&r()},[r]);return s.useEffect(()=>{if(a)return document.addEventListener("keydown",t),()=>document.removeEventListener("keydown",t)},[a,t]),a?e.jsxs("div",{className:"fixed inset-0 z-50 flex items-center justify-center",children:[e.jsx("div",{className:"absolute inset-0 bg-black/60 backdrop-blur-sm",onClick:r}),e.jsxs("div",{className:"relative z-10 w-full max-w-lg mx-4 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl",children:[e.jsxs("div",{className:"flex items-center justify-between px-4 py-3 border-b border-gray-300 dark:border-gray-700",children:[e.jsx("h2",{className:"text-sm font-semibold text-gray-800 dark:text-gray-200",children:d}),e.jsx("button",{onClick:r,className:"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700","aria-label":"Close modal",children:e.jsx(i,{size:16})})]}),e.jsx("div",{className:"p-4",children:o})]})]}):null}export{x as M}; diff --git a/stackunderflow/static/react/assets/Overview-DRVBiWsj.js b/stackunderflow/static/react/assets/Overview-BqyHxuZ_.js similarity index 98% rename from stackunderflow/static/react/assets/Overview-DRVBiWsj.js rename to stackunderflow/static/react/assets/Overview-BqyHxuZ_.js index e0588c8..7f345a1 100644 --- a/stackunderflow/static/react/assets/Overview-DRVBiWsj.js +++ b/stackunderflow/static/react/assets/Overview-BqyHxuZ_.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/OverviewTokenChart-D0W5mYxH.js","assets/react-vendor-B7v2HPaI.js","assets/format-Co_unrac.js","assets/chartTheme-GpkpBpop.js","assets/recharts-C8DDeE7E.js","assets/OverviewCostChart-COSvBbQ2.js"])))=>i.map(i=>d[i]); -import{a as Ee,d as Fe,r as a,l as ze,u as de,e as $e,j as e,_ as xe}from"./react-vendor-B7v2HPaI.js";import{u as Ie,a as Ae,g as Oe,L as qe,I as ne,b as Re,s as Ke,f as Qe,r as Ue,c as We,d as Be,e as Ge}from"./index-DDMGE_ZF.js";import{E as Ve}from"./EmptyState-o0gibvhZ.js";import{P as le}from"./ProviderChip-PiRoWNpI.js";import{f as He,a as w,b as ie}from"./format-Co_unrac.js";import{F as Je}from"./FilterBar-DuduzIn6.js";import{I as Ze,a as Xe}from"./IconArrowUp-CszCUTPq.js";const Ye=a.lazy(()=>xe(()=>import("./OverviewTokenChart-D0W5mYxH.js"),__vite__mapDeps([0,1,2,3,4]))),et=a.lazy(()=>xe(()=>import("./OverviewCostChart-COSvBbQ2.js"),__vite__mapDeps([5,1,2,3,4]))),tt=[{key:"name",label:"Name"},{key:"path",label:"Path"},{key:"anon",label:"Anon"}],ge=[{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"90d",label:"90 days"},{key:"all",label:"All time"}],ce=100;function U(o){const c=new Date;return c.setDate(c.getDate()-o),c.setHours(0,0,0,0),c}function rt(o){return o==="7d"?U(7):o==="30d"?U(30):o==="90d"?U(90):null}function at(o){return new Date(o*1e3).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function st(o,c){if(!o||!c)return"-";const u=new Date(c).getTime()-new Date(o).getTime(),g=Math.floor(u/(1e3*60*60*24));if(g<1)return"<1d";if(g<30)return`${g}d`;const f=Math.floor(g/30),N=g%30;return f<12?N>0?`${f}mo ${N}d`:`${f}mo`:`${Math.floor(f/12)}y ${f%12}mo`}function W(o,c){if(o==="all"&&c)return`Since ${new Date(c).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}`;const u=ge.find(g=>g.key===o);return u?`Last ${u.label}`:""}function ot(o,c){const[u,g]=a.useState(o);return a.useEffect(()=>{const f=setTimeout(()=>g(o),c);return()=>clearTimeout(f)},[o,c]),u}function mt(){var Y,ee,te;const o=Ee(),c=Fe(),{currency:u}=Ie(),[g,f]=a.useState(""),[N,me]=a.useState("last_modified"),[D,B]=a.useState("desc"),[k,R]=a.useState(1),[h,ye]=a.useState(20),[m,ue]=a.useState("30d"),{filters:he}=Ae(),M=he.models[0]??"all",j=M!=="all"?He(M):null,[G,pe]=a.useState(Oe()),be=t=>{pe(t),Ke(t)},$=ot(g,200),{data:p,isLoading:fe,isError:ke,refetch:je,fetchNextPage:ve,hasNextPage:Ne,isFetchingNextPage:V}=ze({queryKey:["projects",!0],queryFn:({pageParam:t})=>We(!0,void 0,{limit:ce,offset:t}),initialPageParam:0,getNextPageParam:t=>{if(!t.has_more)return;const r=(t.offset??0)+(t.limit??ce);return rGe()}),De=fe||we,Me=ke||Ce,K=$e({mutationFn:()=>Ue(new Date().getTimezoneOffset()),onSuccess:()=>{c.invalidateQueries({queryKey:["projects"]}),c.invalidateQueries({queryKey:["globalStats"]})}}),C=a.useMemo(()=>(p==null?void 0:p.pages.flatMap(t=>t.projects))??[],[p]),H=((Y=p==null?void 0:p.pages[0])==null?void 0:Y.total_count)??C.length,s=_e,S=s==null?void 0:s.first_use_date;s==null||s.last_use_date;const Q=(s==null?void 0:s.daily_token_usage)??[],J=(s==null?void 0:s.daily_costs)??[],A=(s==null?void 0:s.models)??{},Se=a.useMemo(()=>{const t=Object.keys(A).filter(r=>r!=="");return t.sort((r,d)=>{var l,n;return(((l=A[d])==null?void 0:l.cost)??0)-(((n=A[r])==null?void 0:n.cost)??0)}),t},[A]),Le=(t,r)=>Qe(t.dir_name,r,G),y=a.useMemo(()=>rt(m),[m]),L=a.useMemo(()=>(y?Q.filter(r=>new Date(r.date)>=y):Q).map(r=>({...r,ts:new Date(`${r.date}T00:00:00`).getTime()})),[Q,y]),O=a.useMemo(()=>{let t=J;return y&&(t=t.filter(r=>new Date(r.date)>=y)),M!=="all"?t.map(r=>{var d;return{...r,cost:((d=r.by_model)==null?void 0:d[M])??0}}):t},[J,y,M]),P=a.useMemo(()=>{if(!y)return C;const t=y.getTime()/1e3;return C.filter(r=>r.last_modified>=t)},[C,y]),Z=(s==null?void 0:s.total_cache_read_tokens)??0,X=(s==null?void 0:s.total_cache_write_tokens)??0,b=a.useMemo(()=>{const t=L.reduce((i,x)=>i+x.input,0),r=L.reduce((i,x)=>i+x.output,0),d=O.reduce((i,x)=>i+(x.cost??0),0),l=(I==null?void 0:I.daily)??[];let n,v;l.length>0?(n=l.filter(i=>!y||new Date(`${i.date}T00:00:00`)>=y).reduce((i,x)=>i+(x.commands??0),0),v=!0):(n=P.reduce((i,x)=>{var z;return i+(((z=x.stats)==null?void 0:z.total_commands)??0)},0),v=!1);const _=m==="all"?Z+X:0;return{totalTokens:t+r,inputTokens:t,outputTokens:r,cacheTokens:_,totalCost:d,totalCommands:n,commandsWindowed:v,projectCount:P.length}},[L,O,P,m,Z,X,I,y]),T=a.useMemo(()=>{let t=P;if($){const r=$.toLowerCase();t=t.filter(d=>d.display_name.toLowerCase().includes(r)||d.dir_name.toLowerCase().includes(r))}return t=[...t].sort((r,d)=>{var v,_,i,x;let l,n;switch(N){case"display_name":l=r.display_name.toLowerCase(),n=d.display_name.toLowerCase();break;case"last_modified":l=r.last_modified,n=d.last_modified;break;case"total_cost":l=((v=r.stats)==null?void 0:v.total_cost)??0,n=((_=d.stats)==null?void 0:_.total_cost)??0;break;case"total_commands":l=((i=r.stats)==null?void 0:i.total_commands)??0,n=((x=d.stats)==null?void 0:x.total_commands)??0;break;case"total_size_mb":l=r.total_size_mb,n=d.total_size_mb;break;default:l=0,n=0}return ln?D==="asc"?1:-1:0}),t},[P,$,N,D]),q=a.useMemo(()=>Math.ceil(T.length/h),[T.length,h]),Pe=a.useMemo(()=>T.slice((k-1)*h,k*h),[T,k,h]);a.useEffect(()=>{R(1)},[$,N,D,h,m]);const E=t=>{N===t?B(r=>r==="asc"?"desc":"asc"):(me(t),B("desc"))},F=({field:t})=>N!==t?null:D==="asc"?e.jsx(Ze,{size:12,className:"inline ml-0.5"}):e.jsx(Xe,{size:12,className:"inline ml-0.5"});return De?e.jsx(qe,{message:"Loading projects..."}):Me?e.jsx("div",{className:"max-w-7xl mx-auto p-6",children:e.jsxs("div",{className:"flex flex-col items-center justify-center gap-3 p-8 text-center",children:[e.jsx("h3",{className:"text-gray-700 dark:text-gray-300 font-medium text-sm",children:"Couldn't load dashboard data"}),e.jsx("p",{className:"text-gray-500 text-xs max-w-sm",children:"The projects or global-stats request failed. Make sure the StackUnderflow server is running, then retry."}),e.jsxs("button",{onClick:()=>{je(),Te()},className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600",children:[e.jsx(ne,{size:14}),"Retry"]})]})}):C.length===0?e.jsx(Ve,{title:"No projects found",description:"Make sure you have Claude Code sessions in ~/.claude/projects/"}):e.jsxs("div",{className:"max-w-7xl mx-auto p-6 space-y-6",children:[e.jsx(Je,{modelOptions:Se}),e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-xl font-bold text-gray-900 dark:text-gray-100",children:"Projects Overview"}),e.jsxs("p",{className:"text-sm text-gray-500 mt-0.5",children:[b.projectCount," projects ",m!=="all"?`active in last ${m.replace("d"," days")}`:"analyzed",m==="all"&&S&&e.jsxs(e.Fragment,{children:[" · since ",new Date(S).toLocaleDateString(void 0,{month:"short",year:"numeric"})]})]})]}),e.jsxs("div",{className:"flex items-center gap-3 flex-wrap",children:[e.jsx("div",{className:"flex items-center bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 overflow-hidden",children:ge.map(({key:t,label:r})=>e.jsx("button",{onClick:()=>ue(t),className:`px-3 py-1.5 text-xs font-medium transition-colors ${m===t?"bg-indigo-600 text-white":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700"}`,children:r},t))}),e.jsxs("button",{onClick:()=>K.mutate(),disabled:K.isPending,className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:[e.jsx(ne,{size:14,className:K.isPending?"animate-spin":""}),"Refresh"]})]})]}),e.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-5 gap-4",children:[e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Projects"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:b.projectCount})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Total Tokens"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:w(b.totalTokens)}),e.jsxs("div",{className:"text-[10px] text-gray-500 mt-0.5",children:["In: ",w(b.inputTokens)," / Out: ",w(b.outputTokens),b.cacheTokens>0&&e.jsxs(e.Fragment,{children:[" / Cache: ",w(b.cacheTokens)," (lifetime)"]})]}),e.jsxs("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:["Input + output · ",W(m,S),j?" · all models":""]})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Est. API Cost"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:ie(b.totalCost,u)}),e.jsxs("div",{className:"text-[10px] text-gray-500 mt-0.5",children:[W(m,S),j?` · ${j}`:""]}),e.jsx("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:"pay-per-token equivalent"})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Commands"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:b.totalCommands.toLocaleString()}),e.jsxs("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:[b.commandsWindowed?W(m,S):"lifetime",j?" · all models":""]})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Cached"}),e.jsxs("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:[((te=(ee=p==null?void 0:p.pages[0])==null?void 0:ee.cache_status)==null?void 0:te.cached_count)??0,"/",H]})]})]}),L.length>0&&e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:["Token Usage Over Time",j?" · all models":""]}),e.jsx(a.Suspense,{fallback:e.jsx("div",{className:"h-[280px] rounded bg-gray-200/40 dark:bg-gray-800/40 animate-pulse"}),children:e.jsx(Ye,{data:L})})]}),O.length>0&&e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:["Daily Cost",j?` · ${j}`:""]}),e.jsx(a.Suspense,{fallback:e.jsx("div",{className:"h-[200px] rounded bg-gray-200/40 dark:bg-gray-800/40 animate-pulse"}),children:e.jsx(et,{data:O,currency:u})})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("div",{className:"relative flex-1 max-w-xs",children:[e.jsx(Re,{size:14,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),e.jsx("input",{type:"text",value:g,onChange:t=>f(t.target.value),placeholder:"Filter projects...",className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded pl-8 pr-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500"})]}),e.jsx("div",{className:"flex items-center bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 overflow-hidden",children:tt.map(({key:t,label:r})=>e.jsx("button",{onClick:()=>be(t),className:`px-2.5 py-1.5 text-xs font-medium transition-colors ${G===t?"bg-violet-600 text-white":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700"}`,children:r},t))}),e.jsxs("select",{value:h,onChange:t=>ye(Number(t.target.value)),className:"bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300",children:[e.jsx("option",{value:10,children:"10"}),e.jsx("option",{value:20,children:"20"}),e.jsx("option",{value:50,children:"50"}),e.jsx("option",{value:100,children:"100"})]})]}),e.jsxs("p",{className:"text-xs text-gray-500",children:["Per-project values below are lifetime totals; the date range filters which projects appear (by last activity)",j?e.jsxs(e.Fragment,{children:[" · the ",e.jsx("span",{className:"font-medium",children:j})," model filter applies to cost only"]}):null,"."]}),e.jsx("div",{className:"bg-gray-100/50 dark:bg-gray-800/30 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider",children:[e.jsxs("th",{className:"text-left px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("display_name"),children:["Project ",e.jsx(F,{field:"display_name"})]}),e.jsxs("th",{className:"text-left px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("last_modified"),children:["Last Active ",e.jsx(F,{field:"last_modified"})]}),e.jsx("th",{className:"text-right px-4 py-3",children:"Span"}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_commands"),children:["Commands ",e.jsx(F,{field:"total_commands"})]}),e.jsx("th",{className:"text-right px-4 py-3",children:"Tokens"}),e.jsx("th",{className:"text-right px-4 py-3",children:"Steps/Cmd"}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_cost"),children:["Est. Cost ",e.jsx(F,{field:"total_cost"})]}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_size_mb"),children:["Size ",e.jsx(F,{field:"total_size_mb"})]})]})}),e.jsx("tbody",{children:Pe.map((t,r)=>{var l,n,v,_,i,x,z,re,ae,se;const d=(((l=t.stats)==null?void 0:l.total_input_tokens)??0)+(((n=t.stats)==null?void 0:n.total_output_tokens)??0);return e.jsxs("tr",{onClick:()=>o(`/project/${encodeURIComponent(t.dir_name)}`),className:"border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-100/70 dark:hover:bg-gray-800/50 cursor-pointer",children:[e.jsxs("td",{className:"px-4 py-3",children:[e.jsxs("div",{className:"flex items-center gap-1.5 flex-wrap",children:[e.jsx("span",{className:"text-gray-800 dark:text-gray-200 font-medium",children:Le(t,(k-1)*h+r)}),(t.provider??"").split(",").filter(Boolean).map(oe=>e.jsx(le,{provider:oe.trim()},oe)),!t.provider&&e.jsx(le,{provider:void 0})]}),e.jsxs("div",{className:"text-xs text-gray-500",children:[t.file_count," sessions"]})]}),e.jsx("td",{className:"px-4 py-3 text-gray-600 dark:text-gray-400 text-xs",children:at(t.last_modified)}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-500 text-xs",children:st(((v=t.stats)==null?void 0:v.first_message_date)??void 0,((_=t.stats)==null?void 0:_.last_message_date)??void 0)}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-700 dark:text-gray-300",children:((x=(i=t.stats)==null?void 0:i.total_commands)==null?void 0:x.toLocaleString())??"-"}),e.jsxs("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:[e.jsx("div",{children:d>0?w(d):"-"}),d>0&&e.jsxs("div",{className:"text-[10px] text-gray-400 dark:text-gray-600",children:[w(((z=t.stats)==null?void 0:z.total_input_tokens)??0)," in / ",w(((re=t.stats)==null?void 0:re.total_output_tokens)??0)," out"]})]}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:(ae=t.stats)!=null&&ae.avg_steps_per_command?t.stats.avg_steps_per_command.toFixed(1):"-"}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-700 dark:text-gray-300",children:((se=t.stats)==null?void 0:se.total_cost)!=null?ie(t.stats.total_cost,u):"-"}),e.jsxs("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:[t.total_size_mb.toFixed(1)," MB"]})]},t.dir_name)})})]})})}),Ne&&e.jsxs("div",{className:"flex items-center justify-center gap-3 text-sm text-gray-600 dark:text-gray-400",children:[e.jsxs("span",{children:[C.length," of ",H," projects loaded"]}),e.jsx("button",{onClick:()=>ve(),disabled:V,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:V?"Loading…":"Load more"})]}),q>1&&e.jsxs("div",{className:"flex items-center justify-between text-sm text-gray-600 dark:text-gray-400",children:[e.jsxs("span",{children:["Showing ",(k-1)*h+1,"-",Math.min(k*h,T.length)," of ",T.length]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{onClick:()=>R(t=>Math.max(1,t-1)),disabled:k<=1,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:"Prev"}),e.jsxs("span",{children:["Page ",k," of ",q]}),e.jsx("button",{onClick:()=>R(t=>Math.min(q,t+1)),disabled:k>=q,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:"Next"})]})]})]})}export{mt as default}; +import{a as Ee,d as Fe,r as a,l as ze,u as de,e as $e,j as e,_ as xe}from"./react-vendor-B7v2HPaI.js";import{u as Ie,a as Ae,g as Oe,L as qe,I as ne,b as Re,s as Ke,f as Qe,r as Ue,c as We,d as Be,e as Ge}from"./index-DQluCO2S.js";import{E as Ve}from"./EmptyState-o0gibvhZ.js";import{P as le}from"./ProviderChip-baymKnS_.js";import{f as He,a as w,b as ie}from"./format-Co_unrac.js";import{F as Je}from"./FilterBar-BS6lhcC1.js";import{I as Ze,a as Xe}from"./IconArrowUp-D8NJ3QQF.js";const Ye=a.lazy(()=>xe(()=>import("./OverviewTokenChart-D0W5mYxH.js"),__vite__mapDeps([0,1,2,3,4]))),et=a.lazy(()=>xe(()=>import("./OverviewCostChart-COSvBbQ2.js"),__vite__mapDeps([5,1,2,3,4]))),tt=[{key:"name",label:"Name"},{key:"path",label:"Path"},{key:"anon",label:"Anon"}],ge=[{key:"7d",label:"7 days"},{key:"30d",label:"30 days"},{key:"90d",label:"90 days"},{key:"all",label:"All time"}],ce=100;function U(o){const c=new Date;return c.setDate(c.getDate()-o),c.setHours(0,0,0,0),c}function rt(o){return o==="7d"?U(7):o==="30d"?U(30):o==="90d"?U(90):null}function at(o){return new Date(o*1e3).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}function st(o,c){if(!o||!c)return"-";const u=new Date(c).getTime()-new Date(o).getTime(),g=Math.floor(u/(1e3*60*60*24));if(g<1)return"<1d";if(g<30)return`${g}d`;const f=Math.floor(g/30),N=g%30;return f<12?N>0?`${f}mo ${N}d`:`${f}mo`:`${Math.floor(f/12)}y ${f%12}mo`}function W(o,c){if(o==="all"&&c)return`Since ${new Date(c).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"})}`;const u=ge.find(g=>g.key===o);return u?`Last ${u.label}`:""}function ot(o,c){const[u,g]=a.useState(o);return a.useEffect(()=>{const f=setTimeout(()=>g(o),c);return()=>clearTimeout(f)},[o,c]),u}function mt(){var Y,ee,te;const o=Ee(),c=Fe(),{currency:u}=Ie(),[g,f]=a.useState(""),[N,me]=a.useState("last_modified"),[D,B]=a.useState("desc"),[k,R]=a.useState(1),[h,ye]=a.useState(20),[m,ue]=a.useState("30d"),{filters:he}=Ae(),M=he.models[0]??"all",j=M!=="all"?He(M):null,[G,pe]=a.useState(Oe()),be=t=>{pe(t),Ke(t)},$=ot(g,200),{data:p,isLoading:fe,isError:ke,refetch:je,fetchNextPage:ve,hasNextPage:Ne,isFetchingNextPage:V}=ze({queryKey:["projects",!0],queryFn:({pageParam:t})=>We(!0,void 0,{limit:ce,offset:t}),initialPageParam:0,getNextPageParam:t=>{if(!t.has_more)return;const r=(t.offset??0)+(t.limit??ce);return rGe()}),De=fe||we,Me=ke||Ce,K=$e({mutationFn:()=>Ue(new Date().getTimezoneOffset()),onSuccess:()=>{c.invalidateQueries({queryKey:["projects"]}),c.invalidateQueries({queryKey:["globalStats"]})}}),C=a.useMemo(()=>(p==null?void 0:p.pages.flatMap(t=>t.projects))??[],[p]),H=((Y=p==null?void 0:p.pages[0])==null?void 0:Y.total_count)??C.length,s=_e,S=s==null?void 0:s.first_use_date;s==null||s.last_use_date;const Q=(s==null?void 0:s.daily_token_usage)??[],J=(s==null?void 0:s.daily_costs)??[],A=(s==null?void 0:s.models)??{},Se=a.useMemo(()=>{const t=Object.keys(A).filter(r=>r!=="");return t.sort((r,d)=>{var l,n;return(((l=A[d])==null?void 0:l.cost)??0)-(((n=A[r])==null?void 0:n.cost)??0)}),t},[A]),Le=(t,r)=>Qe(t.dir_name,r,G),y=a.useMemo(()=>rt(m),[m]),L=a.useMemo(()=>(y?Q.filter(r=>new Date(r.date)>=y):Q).map(r=>({...r,ts:new Date(`${r.date}T00:00:00`).getTime()})),[Q,y]),O=a.useMemo(()=>{let t=J;return y&&(t=t.filter(r=>new Date(r.date)>=y)),M!=="all"?t.map(r=>{var d;return{...r,cost:((d=r.by_model)==null?void 0:d[M])??0}}):t},[J,y,M]),P=a.useMemo(()=>{if(!y)return C;const t=y.getTime()/1e3;return C.filter(r=>r.last_modified>=t)},[C,y]),Z=(s==null?void 0:s.total_cache_read_tokens)??0,X=(s==null?void 0:s.total_cache_write_tokens)??0,b=a.useMemo(()=>{const t=L.reduce((i,x)=>i+x.input,0),r=L.reduce((i,x)=>i+x.output,0),d=O.reduce((i,x)=>i+(x.cost??0),0),l=(I==null?void 0:I.daily)??[];let n,v;l.length>0?(n=l.filter(i=>!y||new Date(`${i.date}T00:00:00`)>=y).reduce((i,x)=>i+(x.commands??0),0),v=!0):(n=P.reduce((i,x)=>{var z;return i+(((z=x.stats)==null?void 0:z.total_commands)??0)},0),v=!1);const _=m==="all"?Z+X:0;return{totalTokens:t+r,inputTokens:t,outputTokens:r,cacheTokens:_,totalCost:d,totalCommands:n,commandsWindowed:v,projectCount:P.length}},[L,O,P,m,Z,X,I,y]),T=a.useMemo(()=>{let t=P;if($){const r=$.toLowerCase();t=t.filter(d=>d.display_name.toLowerCase().includes(r)||d.dir_name.toLowerCase().includes(r))}return t=[...t].sort((r,d)=>{var v,_,i,x;let l,n;switch(N){case"display_name":l=r.display_name.toLowerCase(),n=d.display_name.toLowerCase();break;case"last_modified":l=r.last_modified,n=d.last_modified;break;case"total_cost":l=((v=r.stats)==null?void 0:v.total_cost)??0,n=((_=d.stats)==null?void 0:_.total_cost)??0;break;case"total_commands":l=((i=r.stats)==null?void 0:i.total_commands)??0,n=((x=d.stats)==null?void 0:x.total_commands)??0;break;case"total_size_mb":l=r.total_size_mb,n=d.total_size_mb;break;default:l=0,n=0}return ln?D==="asc"?1:-1:0}),t},[P,$,N,D]),q=a.useMemo(()=>Math.ceil(T.length/h),[T.length,h]),Pe=a.useMemo(()=>T.slice((k-1)*h,k*h),[T,k,h]);a.useEffect(()=>{R(1)},[$,N,D,h,m]);const E=t=>{N===t?B(r=>r==="asc"?"desc":"asc"):(me(t),B("desc"))},F=({field:t})=>N!==t?null:D==="asc"?e.jsx(Ze,{size:12,className:"inline ml-0.5"}):e.jsx(Xe,{size:12,className:"inline ml-0.5"});return De?e.jsx(qe,{message:"Loading projects..."}):Me?e.jsx("div",{className:"max-w-7xl mx-auto p-6",children:e.jsxs("div",{className:"flex flex-col items-center justify-center gap-3 p-8 text-center",children:[e.jsx("h3",{className:"text-gray-700 dark:text-gray-300 font-medium text-sm",children:"Couldn't load dashboard data"}),e.jsx("p",{className:"text-gray-500 text-xs max-w-sm",children:"The projects or global-stats request failed. Make sure the StackUnderflow server is running, then retry."}),e.jsxs("button",{onClick:()=>{je(),Te()},className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600",children:[e.jsx(ne,{size:14}),"Retry"]})]})}):C.length===0?e.jsx(Ve,{title:"No projects found",description:"Make sure you have Claude Code sessions in ~/.claude/projects/"}):e.jsxs("div",{className:"max-w-7xl mx-auto p-6 space-y-6",children:[e.jsx(Je,{modelOptions:Se}),e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-xl font-bold text-gray-900 dark:text-gray-100",children:"Projects Overview"}),e.jsxs("p",{className:"text-sm text-gray-500 mt-0.5",children:[b.projectCount," projects ",m!=="all"?`active in last ${m.replace("d"," days")}`:"analyzed",m==="all"&&S&&e.jsxs(e.Fragment,{children:[" · since ",new Date(S).toLocaleDateString(void 0,{month:"short",year:"numeric"})]})]})]}),e.jsxs("div",{className:"flex items-center gap-3 flex-wrap",children:[e.jsx("div",{className:"flex items-center bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 overflow-hidden",children:ge.map(({key:t,label:r})=>e.jsx("button",{onClick:()=>ue(t),className:`px-3 py-1.5 text-xs font-medium transition-colors ${m===t?"bg-indigo-600 text-white":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700"}`,children:r},t))}),e.jsxs("button",{onClick:()=>K.mutate(),disabled:K.isPending,className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:[e.jsx(ne,{size:14,className:K.isPending?"animate-spin":""}),"Refresh"]})]})]}),e.jsxs("div",{className:"grid grid-cols-2 md:grid-cols-5 gap-4",children:[e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Projects"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:b.projectCount})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Total Tokens"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:w(b.totalTokens)}),e.jsxs("div",{className:"text-[10px] text-gray-500 mt-0.5",children:["In: ",w(b.inputTokens)," / Out: ",w(b.outputTokens),b.cacheTokens>0&&e.jsxs(e.Fragment,{children:[" / Cache: ",w(b.cacheTokens)," (lifetime)"]})]}),e.jsxs("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:["Input + output · ",W(m,S),j?" · all models":""]})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Est. API Cost"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:ie(b.totalCost,u)}),e.jsxs("div",{className:"text-[10px] text-gray-500 mt-0.5",children:[W(m,S),j?` · ${j}`:""]}),e.jsx("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:"pay-per-token equivalent"})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Commands"}),e.jsx("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:b.totalCommands.toLocaleString()}),e.jsxs("div",{className:"text-[9px] text-gray-400 dark:text-gray-600 mt-0.5",children:[b.commandsWindowed?W(m,S):"lifetime",j?" · all models":""]})]}),e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsx("div",{className:"text-xs text-gray-500 uppercase tracking-wider",children:"Cached"}),e.jsxs("div",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1",children:[((te=(ee=p==null?void 0:p.pages[0])==null?void 0:ee.cache_status)==null?void 0:te.cached_count)??0,"/",H]})]})]}),L.length>0&&e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:["Token Usage Over Time",j?" · all models":""]}),e.jsx(a.Suspense,{fallback:e.jsx("div",{className:"h-[280px] rounded bg-gray-200/40 dark:bg-gray-800/40 animate-pulse"}),children:e.jsx(Ye,{data:L})})]}),O.length>0&&e.jsxs("div",{className:"bg-gray-100/70 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-800",children:[e.jsxs("h3",{className:"text-sm font-medium text-gray-700 dark:text-gray-300 mb-3",children:["Daily Cost",j?` · ${j}`:""]}),e.jsx(a.Suspense,{fallback:e.jsx("div",{className:"h-[200px] rounded bg-gray-200/40 dark:bg-gray-800/40 animate-pulse"}),children:e.jsx(et,{data:O,currency:u})})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("div",{className:"relative flex-1 max-w-xs",children:[e.jsx(Re,{size:14,className:"absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500"}),e.jsx("input",{type:"text",value:g,onChange:t=>f(t.target.value),placeholder:"Filter projects...",className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded pl-8 pr-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500"})]}),e.jsx("div",{className:"flex items-center bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 overflow-hidden",children:tt.map(({key:t,label:r})=>e.jsx("button",{onClick:()=>be(t),className:`px-2.5 py-1.5 text-xs font-medium transition-colors ${G===t?"bg-violet-600 text-white":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700"}`,children:r},t))}),e.jsxs("select",{value:h,onChange:t=>ye(Number(t.target.value)),className:"bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300",children:[e.jsx("option",{value:10,children:"10"}),e.jsx("option",{value:20,children:"20"}),e.jsx("option",{value:50,children:"50"}),e.jsx("option",{value:100,children:"100"})]})]}),e.jsxs("p",{className:"text-xs text-gray-500",children:["Per-project values below are lifetime totals; the date range filters which projects appear (by last activity)",j?e.jsxs(e.Fragment,{children:[" · the ",e.jsx("span",{className:"font-medium",children:j})," model filter applies to cost only"]}):null,"."]}),e.jsx("div",{className:"bg-gray-100/50 dark:bg-gray-800/30 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden",children:e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full text-sm",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider",children:[e.jsxs("th",{className:"text-left px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("display_name"),children:["Project ",e.jsx(F,{field:"display_name"})]}),e.jsxs("th",{className:"text-left px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("last_modified"),children:["Last Active ",e.jsx(F,{field:"last_modified"})]}),e.jsx("th",{className:"text-right px-4 py-3",children:"Span"}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_commands"),children:["Commands ",e.jsx(F,{field:"total_commands"})]}),e.jsx("th",{className:"text-right px-4 py-3",children:"Tokens"}),e.jsx("th",{className:"text-right px-4 py-3",children:"Steps/Cmd"}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_cost"),children:["Est. Cost ",e.jsx(F,{field:"total_cost"})]}),e.jsxs("th",{className:"text-right px-4 py-3 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200",onClick:()=>E("total_size_mb"),children:["Size ",e.jsx(F,{field:"total_size_mb"})]})]})}),e.jsx("tbody",{children:Pe.map((t,r)=>{var l,n,v,_,i,x,z,re,ae,se;const d=(((l=t.stats)==null?void 0:l.total_input_tokens)??0)+(((n=t.stats)==null?void 0:n.total_output_tokens)??0);return e.jsxs("tr",{onClick:()=>o(`/project/${encodeURIComponent(t.dir_name)}`),className:"border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-100/70 dark:hover:bg-gray-800/50 cursor-pointer",children:[e.jsxs("td",{className:"px-4 py-3",children:[e.jsxs("div",{className:"flex items-center gap-1.5 flex-wrap",children:[e.jsx("span",{className:"text-gray-800 dark:text-gray-200 font-medium",children:Le(t,(k-1)*h+r)}),(t.provider??"").split(",").filter(Boolean).map(oe=>e.jsx(le,{provider:oe.trim()},oe)),!t.provider&&e.jsx(le,{provider:void 0})]}),e.jsxs("div",{className:"text-xs text-gray-500",children:[t.file_count," sessions"]})]}),e.jsx("td",{className:"px-4 py-3 text-gray-600 dark:text-gray-400 text-xs",children:at(t.last_modified)}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-500 text-xs",children:st(((v=t.stats)==null?void 0:v.first_message_date)??void 0,((_=t.stats)==null?void 0:_.last_message_date)??void 0)}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-700 dark:text-gray-300",children:((x=(i=t.stats)==null?void 0:i.total_commands)==null?void 0:x.toLocaleString())??"-"}),e.jsxs("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:[e.jsx("div",{children:d>0?w(d):"-"}),d>0&&e.jsxs("div",{className:"text-[10px] text-gray-400 dark:text-gray-600",children:[w(((z=t.stats)==null?void 0:z.total_input_tokens)??0)," in / ",w(((re=t.stats)==null?void 0:re.total_output_tokens)??0)," out"]})]}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:(ae=t.stats)!=null&&ae.avg_steps_per_command?t.stats.avg_steps_per_command.toFixed(1):"-"}),e.jsx("td",{className:"px-4 py-3 text-right text-gray-700 dark:text-gray-300",children:((se=t.stats)==null?void 0:se.total_cost)!=null?ie(t.stats.total_cost,u):"-"}),e.jsxs("td",{className:"px-4 py-3 text-right text-gray-600 dark:text-gray-400",children:[t.total_size_mb.toFixed(1)," MB"]})]},t.dir_name)})})]})})}),Ne&&e.jsxs("div",{className:"flex items-center justify-center gap-3 text-sm text-gray-600 dark:text-gray-400",children:[e.jsxs("span",{children:[C.length," of ",H," projects loaded"]}),e.jsx("button",{onClick:()=>ve(),disabled:V,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:V?"Loading…":"Load more"})]}),q>1&&e.jsxs("div",{className:"flex items-center justify-between text-sm text-gray-600 dark:text-gray-400",children:[e.jsxs("span",{children:["Showing ",(k-1)*h+1,"-",Math.min(k*h,T.length)," of ",T.length]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{onClick:()=>R(t=>Math.max(1,t-1)),disabled:k<=1,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:"Prev"}),e.jsxs("span",{children:["Page ",k," of ",q]}),e.jsx("button",{onClick:()=>R(t=>Math.min(q,t+1)),disabled:k>=q,className:"px-3 py-1 bg-white dark:bg-gray-800 rounded border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:"Next"})]})]})]})}export{mt as default}; diff --git a/stackunderflow/static/react/assets/OverviewTab-BptDgpTp.js b/stackunderflow/static/react/assets/OverviewTab-CSNvLBfV.js similarity index 98% rename from stackunderflow/static/react/assets/OverviewTab-BptDgpTp.js rename to stackunderflow/static/react/assets/OverviewTab-CSNvLBfV.js index bb049c1..12b5dd5 100644 --- a/stackunderflow/static/react/assets/OverviewTab-BptDgpTp.js +++ b/stackunderflow/static/react/assets/OverviewTab-CSNvLBfV.js @@ -1,4 +1,4 @@ -import{u as B,j as e,r as c}from"./react-vendor-B7v2HPaI.js";import{h as R,u as P,N as he,p as pe,O as we,B as H,P as Fe,i as ye,j as je,Q as Te,a as J,R as ke}from"./index-DDMGE_ZF.js";import{c as m,b as u,f as Z,a as fe}from"./format-Co_unrac.js";import{I as be}from"./IconHash-Bsu2htWI.js";import{b as _e,c as ve,d as Ce,e as Ie}from"./dashboardTabs-CJWioGLA.js";import{I as $e,a as Me,b as Re,T as Oe,C as Se,c as De,d as ze}from"./TokenCompositionDonut-DsJdUSqI.js";import{u as v,E as C,C as N,a as T}from"./chartTheme-GpkpBpop.js";import{R as L,A as Be,C as I,X as $,Y as j,T as A,L as S,a as U,B as q,b as p,P as Ee,c as Ue,d as W,e as ee,f as te}from"./recharts-C8DDeE7E.js";import{I as Ke}from"./IconTrendingUp-DvwC6rkL.js";import{I as Pe}from"./IconArrowRight-xIaEMpRY.js";import{I as He}from"./IconFileText-C9y_jNvO.js";import{I as qe,s as We}from"./ProjectDashboard-DRHQbkve.js";import{I as Ye}from"./IconCheck-DwMBTi_m.js";import{I as Ge}from"./IconCopy-DA8lvnPB.js";import{I as Ne,a as Ve}from"./IconUser-ectHbzBi.js";import{I as Qe}from"./IconClockHour4-UHU_zj__.js";import{I as Xe}from"./IconRobot-BQwymFKA.js";import"./EmptyState-o0gibvhZ.js";import"./FilterBar-DuduzIn6.js";/** +import{u as B,j as e,r as c}from"./react-vendor-B7v2HPaI.js";import{h as R,u as P,N as he,p as pe,O as we,B as H,P as Fe,i as ye,j as je,Q as Te,a as J,R as ke}from"./index-DQluCO2S.js";import{c as m,b as u,f as Z,a as fe}from"./format-Co_unrac.js";import{I as be}from"./IconHash-cdeuBxnj.js";import{b as _e,c as ve,d as Ce,e as Ie}from"./dashboardTabs-C5ecR5YO.js";import{I as $e,a as Me,b as Re,T as Oe,C as Se,c as De,d as ze}from"./TokenCompositionDonut-DTEMWIwY.js";import{u as v,E as C,C as N,a as T}from"./chartTheme-GpkpBpop.js";import{R as L,A as Be,C as I,X as $,Y as j,T as A,L as S,a as U,B as q,b as p,P as Ee,c as Ue,d as W,e as ee,f as te}from"./recharts-C8DDeE7E.js";import{I as Ke}from"./IconTrendingUp-D1iuYAG9.js";import{I as Pe}from"./IconArrowRight-lOOgkPXu.js";import{I as He}from"./IconFileText-ChD_eRqq.js";import{I as qe,s as We}from"./ProjectDashboard-DAsn11K-.js";import{I as Ye}from"./IconCheck-BQLu3HFV.js";import{I as Ge}from"./IconCopy-BOYWZx4M.js";import{I as Ne,a as Ve}from"./IconUser-IjRNaThB.js";import{I as Qe}from"./IconClockHour4-BUN27e3Z.js";import{I as Xe}from"./IconRobot-DuCKb11z.js";import"./EmptyState-o0gibvhZ.js";import"./FilterBar-BS6lhcC1.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/PlaybackTab-DX5SA_Uq.js b/stackunderflow/static/react/assets/PlaybackTab-0L9gLEFP.js similarity index 99% rename from stackunderflow/static/react/assets/PlaybackTab-DX5SA_Uq.js rename to stackunderflow/static/react/assets/PlaybackTab-0L9gLEFP.js index 510ee4b..4abe4fd 100644 --- a/stackunderflow/static/react/assets/PlaybackTab-DX5SA_Uq.js +++ b/stackunderflow/static/react/assets/PlaybackTab-0L9gLEFP.js @@ -1,4 +1,4 @@ -import{r as l,j as e,u as A}from"./react-vendor-B7v2HPaI.js";import{h as M,p as z,G as V,a9 as O,aa as re,i as ae,j as X,ab as se,L as ne,T as ie,a3 as le,ac as oe}from"./index-DDMGE_ZF.js";import{E as K}from"./EmptyState-o0gibvhZ.js";import{I as U}from"./IconClock-Dl-xdEIm.js";import{I as de}from"./IconFileText-C9y_jNvO.js";import{i as ce,j as H}from"./dashboardTabs-CJWioGLA.js";import{I as xe,a as ge}from"./IconPlayerSkipForwardFilled-DO-WPkWS.js";/** +import{r as l,j as e,u as A}from"./react-vendor-B7v2HPaI.js";import{h as M,p as z,G as V,a9 as O,aa as re,i as ae,j as X,ab as se,L as ne,T as ie,a3 as le,ac as oe}from"./index-DQluCO2S.js";import{E as K}from"./EmptyState-o0gibvhZ.js";import{I as U}from"./IconClock-CgB7iTL4.js";import{I as de}from"./IconFileText-ChD_eRqq.js";import{i as ce,j as H}from"./dashboardTabs-C5ecR5YO.js";import{I as xe,a as ge}from"./IconPlayerSkipForwardFilled-DA8tH9T1.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/ProjectDashboard-DAsn11K-.js b/stackunderflow/static/react/assets/ProjectDashboard-DAsn11K-.js new file mode 100644 index 0000000..cf4571a --- /dev/null +++ b/stackunderflow/static/react/assets/ProjectDashboard-DAsn11K-.js @@ -0,0 +1,7 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/OverviewTab-CSNvLBfV.js","assets/react-vendor-B7v2HPaI.js","assets/index-DQluCO2S.js","assets/index-DL9ELn-S.css","assets/format-Co_unrac.js","assets/IconHash-cdeuBxnj.js","assets/dashboardTabs-C5ecR5YO.js","assets/TokenCompositionDonut-DTEMWIwY.js","assets/IconTrendingUp-D1iuYAG9.js","assets/chartTheme-GpkpBpop.js","assets/recharts-C8DDeE7E.js","assets/IconArrowRight-lOOgkPXu.js","assets/IconFileText-ChD_eRqq.js","assets/IconCheck-BQLu3HFV.js","assets/IconCopy-BOYWZx4M.js","assets/IconUser-IjRNaThB.js","assets/IconClockHour4-BUN27e3Z.js","assets/IconRobot-DuCKb11z.js","assets/EmptyState-o0gibvhZ.js","assets/FilterBar-BS6lhcC1.js","assets/CommandsTab-DlkmMFl4.js","assets/DataTable-DCEy-2Es.js","assets/IconArrowUp-D8NJ3QQF.js","assets/MessagesTab-Dull7JZB.js","assets/Modal-DkeyqO4J.js","assets/Markdown-Be0CkqQs.js","assets/syntax-highlighter-BYF-y4SB.js","assets/markdown-DgJRk3H5.js","assets/ProviderChip-baymKnS_.js","assets/SearchTab-DsaZoPy9.js","assets/TimeAgo-BNE6wf6R.js","assets/IconAlertCircle-h2zR9MWj.js","assets/QATab-DUs6uRU9.js","assets/IconSortDescending-DunfClYo.js","assets/IconCode-nzK0btv1.js","assets/BookmarksTab-Cor0cmyo.js","assets/TagsTab-DSK8Zu2d.js","assets/SessionsTab-NrDPSNfB.js","assets/EstimatedCostMarker-BpsBXEjf.js","assets/IconClock-CgB7iTL4.js","assets/AgentsTab-BajlmbLz.js","assets/PlaybackTab-0L9gLEFP.js","assets/IconPlayerSkipForwardFilled-DA8tH9T1.js","assets/ContextReplayTab-ixP9t9_Y.js","assets/CostTab-DCJQfNNf.js","assets/BudgetsTab-BjHaaUsj.js","assets/CompareTab-D60fXyc4.js","assets/YieldTab-BbHHGYlL.js","assets/IconCircleX-D7ABaoUn.js","assets/ForksTab-ryk22jFG.js","assets/CodingHealthTab-B02yOcgC.js","assets/IconBolt-CWu2Wz0a.js","assets/WorktreesTab-B65wyswE.js","assets/IconActivity-Bf5MQINx.js"])))=>i.map(i=>d[i]); +import{r as s,j as e,m as H,d as X,u as F,e as Z,_ as c}from"./react-vendor-B7v2HPaI.js";import{h as ee,i as te,j as ae,a as re,f as se,g as oe,L as U,I as M,E as ne,r as ie,k as de,l as ce}from"./index-DQluCO2S.js";import{E as le}from"./EmptyState-o0gibvhZ.js";import{F as ue}from"./FilterBar-BS6lhcC1.js";import{I as xe,u as me,T as P,B as pe,a as q,D as ge}from"./dashboardTabs-C5ecR5YO.js";/** + * @license @tabler/icons-react v3.36.1 - MIT + * + * This source code is licensed under the MIT license. + * See the LICENSE file in the root directory of this source tree. + */const be=[["path",{d:"M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2",key:"svg-0"}],["path",{d:"M7 11l5 5l5 -5",key:"svg-1"}],["path",{d:"M12 4l0 12",key:"svg-2"}]],K=ee("outline","download","Download",be),D="stackunderflow:nav";function f(){return typeof window<"u"&&typeof window.history<"u"}function R(t){f()&&window.dispatchEvent(new CustomEvent(D,{detail:t}))}function S(t,n=[]){if(!f())return"";const r=new URL(window.location.href);for(const b of n)r.searchParams.delete(b);for(const[b,y]of Object.entries(t))r.searchParams.set(b,y);const a=`${r.pathname}${r.search}${r.hash}`,x=`${window.location.pathname}${window.location.search}${window.location.hash}`;return a!==x&&window.history.pushState({},"",a),r.search}function Ge(t){S({tab:"messages",interaction:t},["session"]),R({tab:"messages",interaction:t})}function He(t){S({tab:"sessions",session:t},["interaction"]),R({tab:"sessions",session:t})}function ye(t,n){const r={tab:t,...n??{}};S(r,["session","interaction"]),R(r)}function Q(){return f()?new URLSearchParams(window.location.search).get("tab"):null}function L(t){return f()?new URLSearchParams(window.location.search).get(t):null}function J(t){if(!f())return;const n=new URL(window.location.href);if(!n.searchParams.has(t))return;n.searchParams.delete(t);const r=`${n.pathname}${n.search}${n.hash}`;window.history.replaceState({},"",r)}const he=[{value:"today",label:"Today"},{value:"week",label:"Last 7 days"},{value:"month",label:"Last 30 days"},{value:"all",label:"All time"}],fe=[{value:"csv",label:"CSV"},{value:"json",label:"JSON"}];function we({tab:t,className:n}){const[r,a]=s.useState(!1),[x,b]=s.useState("csv"),[y,w]=s.useState("week"),h=s.useRef(null);s.useEffect(()=>{if(!r)return;const i=u=>{h.current&&(h.current.contains(u.target)||a(!1))},p=u=>{u.key==="Escape"&&a(!1)};return window.addEventListener("mousedown",i),window.addEventListener("keydown",p),()=>{window.removeEventListener("mousedown",i),window.removeEventListener("keydown",p)}},[r]);const v=()=>{const p=`/api/export?${new URLSearchParams({format:x,period:y}).toString()}`,u=document.createElement("a");u.href=p,u.rel="noopener",u.download="",document.body.appendChild(u),u.click(),document.body.removeChild(u),a(!1)};return e.jsxs("div",{ref:h,className:`relative ${n??""}`,children:[e.jsxs("button",{type:"button",onClick:()=>a(i=>!i),"aria-haspopup":"menu","aria-expanded":r,title:`Export ${t} data`,"data-testid":`export-button-${t}`,className:"inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 hover:text-gray-900 dark:hover:text-white",children:[e.jsx(K,{size:13}),"Export",e.jsx(te,{size:11,className:r?"rotate-180 transition-transform":"transition-transform"})]}),r&&e.jsxs("div",{role:"menu",className:"absolute right-0 top-full mt-1 z-20 w-56 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-md shadow-lg p-3 space-y-3","data-testid":`export-popover-${t}`,children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"Format"}),e.jsx("div",{className:"flex gap-1",children:fe.map(i=>{const p=x===i.value;return e.jsx("button",{type:"button",onClick:()=>b(i.value),"aria-pressed":p,className:"flex-1 text-xs px-2 py-1 rounded border transition-colors "+(p?"bg-indigo-500/20 border-indigo-500/60 text-indigo-700 dark:text-indigo-200":"bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600"),children:i.label},i.value)})})]}),e.jsxs("div",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"Period"}),e.jsx("select",{value:y,onChange:i=>w(i.target.value),className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1 text-xs text-gray-700 dark:text-gray-300 focus:outline-none focus:border-indigo-500","aria-label":"Period",children:he.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})]}),e.jsxs("button",{type:"button",onClick:v,className:"w-full inline-flex items-center justify-center gap-1.5 px-2 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded","data-testid":`export-download-${t}`,children:[e.jsx(K,{size:13}),"Download"]})]})]})}function ve({trail:t}){return t.length===0?null:e.jsx("nav",{"aria-label":"Breadcrumb",className:"flex items-center text-xs text-gray-600 dark:text-gray-400",children:e.jsx("ol",{className:"flex items-center flex-wrap gap-1",children:t.map((n,r)=>{const a=r===t.length-1,x=!a&&typeof n.onClick=="function";return e.jsxs(s.Fragment,{children:[e.jsx("li",{className:"flex items-center",children:x?e.jsx("button",{type:"button",onClick:n.onClick,className:"text-gray-600 dark:text-gray-400 hover:text-indigo-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400 rounded px-0.5",children:n.label}):e.jsx("span",{className:a?"text-gray-800 dark:text-gray-200 font-medium":"text-gray-600 dark:text-gray-400","aria-current":a?"page":void 0,children:n.label})}),!a&&e.jsx("li",{"aria-hidden":"true",className:"flex items-center text-gray-400 dark:text-gray-600",children:e.jsx(ae,{size:12})})]},`${r}-${n.label}`)})})})}function _e(){return e.jsxs("button",{type:"button","aria-label":"Go back",onClick:()=>{typeof window<"u"&&window.history.back()},className:"inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400",children:[e.jsx(xe,{size:12}),"Back"]})}const je=s.lazy(()=>c(()=>import("./OverviewTab-CSNvLBfV.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]))),ke=s.lazy(()=>c(()=>import("./CommandsTab-DlkmMFl4.js"),__vite__mapDeps([20,1,21,2,3,22,4,18,19,6]))),Ee=s.lazy(()=>c(()=>import("./MessagesTab-Dull7JZB.js"),__vite__mapDeps([23,1,21,2,3,22,24,19,4,25,26,27,28,18,6]))),Te=s.lazy(()=>c(()=>import("./SearchTab-DsaZoPy9.js"),__vite__mapDeps([29,1,2,3,18,30,4,31]))),Ne=s.lazy(()=>c(()=>import("./QATab-DUs6uRU9.js"),__vite__mapDeps([32,1,2,3,18,30,4,33,34]))),Le=s.lazy(()=>c(()=>import("./BookmarksTab-Cor0cmyo.js"),__vite__mapDeps([35,1,2,3,18,19,4,24,30,33,6,13]))),Pe=s.lazy(()=>c(()=>import("./TagsTab-DSK8Zu2d.js"),__vite__mapDeps([36,1,2,3,18,5,6]))),De=s.lazy(()=>c(()=>import("./SessionsTab-NrDPSNfB.js"),__vite__mapDeps([37,1,2,3,18,25,26,27,28,38,4,19,6,5,15,17,39,34]))),Re=s.lazy(()=>c(()=>import("./AgentsTab-BajlmbLz.js"),__vite__mapDeps([40,1,2,3,4,18,6,17,15,5,39]))),Se=s.lazy(()=>c(()=>import("./PlaybackTab-0L9gLEFP.js"),__vite__mapDeps([41,1,2,3,18,39,12,6,42]))),Ae=s.lazy(()=>c(()=>import("./ContextReplayTab-ixP9t9_Y.js"),__vite__mapDeps([43,1,2,3,18,4,42]))),Ie=s.lazy(()=>c(()=>import("./CostTab-DCJQfNNf.js"),__vite__mapDeps([44,1,2,3,4,7,6,5,8,9,10,38,22,19,18]))),Oe=s.lazy(()=>c(()=>import("./BudgetsTab-BjHaaUsj.js"),__vite__mapDeps([45,1,2,3,18,4,6,13,10]))),ze=s.lazy(()=>c(()=>import("./CompareTab-D60fXyc4.js"),__vite__mapDeps([46,1,2,3,18,28,4,6,31]))),Ce=s.lazy(()=>c(()=>import("./YieldTab-BbHHGYlL.js"),__vite__mapDeps([47,1,2,3,18,4,6,48,8]))),$e=s.lazy(()=>c(()=>import("./ForksTab-ryk22jFG.js"),__vite__mapDeps([49,1,2,3,18,4,6,17]))),Be=s.lazy(()=>c(()=>import("./CodingHealthTab-B02yOcgC.js"),__vite__mapDeps([50,1,2,3,18,6,12,51,19,4]))),Ve=s.lazy(()=>c(()=>import("./WorktreesTab-B65wyswE.js"),__vite__mapDeps([52,1,2,3,18,4,6,53,13,14]))),Fe=P.map(t=>t.id);function W(t){return!!t&&Fe.includes(t)}function Ue(){const t=Q();return W(t)?t:"overview"}function Me(t){if(!t)return[];const n=Object.entries(t).filter(([r])=>r!==""&&r.toLowerCase()!=="unknown").map(([r,a])=>({model:r,count:typeof(a==null?void 0:a.count)=="number"?a.count:0}));return n.sort((r,a)=>a.count-r.count),n.slice(0,6).map(r=>r.model)}function qe(){var $,B;const{name:t}=H(),n=X(),{filters:r}=re(),[a,x]=s.useState(Ue),[b,y]=s.useState(0),{isTabVisible:w,betaEnabled:h,setBetaEnabled:v}=me(),i=s.useMemo(()=>P.filter(o=>w(o.id,o.isBeta??!1)),[w]);s.useEffect(()=>{if(typeof window>"u")return;const o=d=>{if(d.key!==null&&d.key!==q)return;let l=ge;try{const k=window.localStorage.getItem(q);if(k!==null){const V=JSON.parse(k);typeof V=="boolean"&&(l=V)}}catch{}l!==h&&v(l)};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[h,v]),s.useEffect(()=>{i.some(o=>o.id===a)||x("overview")},[i,a]);const{isLoading:p,error:u}=F({queryKey:["setProject",t],queryFn:()=>de(t),enabled:!!t,staleTime:6e4}),{data:g,isLoading:_,isFetching:E,error:T}=F({queryKey:["dashboardData",t,r.providers,r.models],queryFn:()=>ce(new Date().getTimezoneOffset(),{providers:r.providers,models:r.models}),enabled:!!t&&!p,placeholderData:(o,d)=>{var l;return((l=d==null?void 0:d.queryKey)==null?void 0:l[1])===t?o:void 0}}),N=Z({mutationFn:()=>ie(new Date().getTimezoneOffset()),onSuccess:()=>{n.invalidateQueries({queryKey:["dashboardData",t]})}}),Y=s.useCallback(o=>{if(x(o),typeof window>"u")return;const d=new URL(window.location.href);d.searchParams.set("tab",o);const l=`${d.pathname}${d.search}${d.hash}`,k=`${window.location.pathname}${window.location.search}${window.location.hash}`;l!==k&&window.history.replaceState({},"",l)},[]),A=s.useCallback((o,d)=>{x(o),ye(o,d)},[]);s.useEffect(()=>{if(!(typeof window>"u"))return window.__suSwitchTab=A,()=>{delete window.__suSwitchTab}},[A]),s.useEffect(()=>{if(typeof window>"u")return;const o=()=>{const d=Q();W(d)&&x(l=>l===d?l:d),y(l=>l+1)};return window.addEventListener(D,o),window.addEventListener("popstate",o),()=>{window.removeEventListener(D,o),window.removeEventListener("popstate",o)}},[]);const I=t?se(t,void 0,oe()):"";if(p||_)return e.jsx(U,{message:`Loading ${I}...`});if(u||T&&!g){const o=u||T;return e.jsx("div",{className:"p-6",children:e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-400 text-sm",children:["Failed to load project: ",o instanceof Error?o.message:"Unknown error"]})})}if(!g)return e.jsx(le,{title:"No data",description:"No dashboard data available"});const m=g.statistics,G=L("q")??"",O=L("session"),z=L("interaction"),C=(($=P.find(o=>o.id===a))==null?void 0:$.label)??a;let j=null;return O?j=[{label:C,onClick:()=>J("session")},{label:`Session · ${O}`}]:z&&(j=[{label:C,onClick:()=>J("interaction")},{label:`Interaction · ${z}`}]),e.jsxs("div",{className:"max-w-7xl mx-auto px-6 py-4 space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-lg font-bold text-gray-900 dark:text-gray-100",children:I}),((B=m==null?void 0:m.overview)==null?void 0:B.date_range)&&e.jsxs("p",{className:"text-xs text-gray-500",children:[m.overview.date_range.start," — ",m.overview.date_range.end]})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[E&&!_&&e.jsxs("span",{className:"flex items-center gap-1.5 text-xs text-gray-500","aria-live":"polite",children:[e.jsx(M,{size:12,className:"animate-spin"}),"Updating…"]}),T&&g&&e.jsx("span",{className:"text-xs text-red-600 dark:text-red-400",role:"status",children:"Refresh failed"}),g.is_reindexing&&e.jsx("span",{className:"text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-2 py-1 rounded",children:"Reindexing..."}),e.jsxs("button",{onClick:()=>N.mutate(),disabled:N.isPending,className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:[e.jsx(M,{size:14,className:N.isPending?"animate-spin":""}),"Refresh"]})]})]}),e.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800 flex items-center justify-between gap-3",children:[e.jsx("nav",{className:"flex gap-0 -mb-px overflow-x-auto flex-1 min-w-0","data-testid":"dashboard-tabs",children:i.map(o=>{const d=o.icon;return e.jsxs("button",{onClick:()=>Y(o.id),"data-tab":o.id,className:`flex items-center gap-1.5 px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 ${a===o.id?"text-indigo-400 border-indigo-400":"text-gray-600 dark:text-gray-400 border-transparent hover:text-gray-800 dark:hover:text-gray-200 hover:border-gray-400 dark:hover:border-gray-600"}`,children:[e.jsx(d,{size:14}),o.label,o.isBeta===!0&&e.jsx(pe,{className:"ml-1.5"})]},o.id)})}),e.jsx("div",{className:"pb-1.5 flex-shrink-0",children:e.jsx(we,{tab:a})})]}),e.jsx(ue,{modelOptions:Me(m==null?void 0:m.models)}),j&&e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx(_e,{}),e.jsx(ve,{trail:j})]}),e.jsx("div",{"aria-busy":E&&!_,className:`transition-opacity duration-200 ${E&&!_?"opacity-60":""}`,children:e.jsx(ne,{children:e.jsxs(s.Suspense,{fallback:e.jsx(U,{size:"md",message:"Loading..."}),children:[a==="overview"&&e.jsx(je,{stats:m}),a==="cost"&&e.jsx(Ie,{stats:m}),a==="budgets"&&e.jsx(Oe,{}),a==="compare"&&e.jsx(ze,{}),a==="yield"&&e.jsx(Ce,{}),a==="forks"&&e.jsx($e,{projectName:t}),a==="health"&&e.jsx(Be,{projectName:t}),a==="worktrees"&&e.jsx(Ve,{projectName:t}),a==="commands"&&e.jsx(ke,{data:g}),a==="messages"&&e.jsx(Ee,{data:g,projectName:t}),a==="search"&&e.jsx(Te,{projectName:t,initialQuery:G}),a==="qa"&&e.jsx(Ne,{projectName:t}),a==="bookmarks"&&e.jsx(Le,{}),a==="tags"&&e.jsx(Pe,{}),a==="sessions"&&e.jsx(De,{projectName:t,sessionEfficiency:m.session_efficiency}),a==="agents"&&e.jsx(Re,{projectName:t}),a==="playback"&&e.jsx(Se,{projectName:t}),a==="contextreplay"&&e.jsx(Ae,{projectName:t})]})},a)})]})}const Xe=Object.freeze(Object.defineProperty({__proto__:null,default:qe},Symbol.toStringTag,{value:"Module"}));export{K as I,D as N,Xe as P,Ge as a,J as c,L as g,He as o,ye as s}; diff --git a/stackunderflow/static/react/assets/ProjectDashboard-DRHQbkve.js b/stackunderflow/static/react/assets/ProjectDashboard-DRHQbkve.js deleted file mode 100644 index 422692d..0000000 --- a/stackunderflow/static/react/assets/ProjectDashboard-DRHQbkve.js +++ /dev/null @@ -1,7 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/OverviewTab-BptDgpTp.js","assets/react-vendor-B7v2HPaI.js","assets/index-DDMGE_ZF.js","assets/index-DL9ELn-S.css","assets/format-Co_unrac.js","assets/IconHash-Bsu2htWI.js","assets/dashboardTabs-CJWioGLA.js","assets/TokenCompositionDonut-DsJdUSqI.js","assets/IconTrendingUp-DvwC6rkL.js","assets/chartTheme-GpkpBpop.js","assets/recharts-C8DDeE7E.js","assets/IconArrowRight-xIaEMpRY.js","assets/IconFileText-C9y_jNvO.js","assets/IconCheck-DwMBTi_m.js","assets/IconCopy-DA8lvnPB.js","assets/IconUser-ectHbzBi.js","assets/IconClockHour4-UHU_zj__.js","assets/IconRobot-BQwymFKA.js","assets/EmptyState-o0gibvhZ.js","assets/FilterBar-DuduzIn6.js","assets/CommandsTab-D4oiPDTW.js","assets/DataTable-D0JcWuTA.js","assets/IconArrowUp-CszCUTPq.js","assets/MessagesTab-Dcm_kMZs.js","assets/Modal-Da9xsK-A.js","assets/Markdown-Be0CkqQs.js","assets/syntax-highlighter-BYF-y4SB.js","assets/markdown-DgJRk3H5.js","assets/ProviderChip-PiRoWNpI.js","assets/SearchTab-BNg4qG1t.js","assets/TimeAgo-BNE6wf6R.js","assets/IconAlertCircle-DyBnJSK0.js","assets/QATab-Dgj7TjI_.js","assets/IconSortDescending-nc0XizMI.js","assets/IconCode-DaQ_eMLh.js","assets/BookmarksTab-DAjJ72Pj.js","assets/TagsTab-CBR6F8Z3.js","assets/SessionsTab-1seW1KtH.js","assets/EstimatedCostMarker-DkSj8xDc.js","assets/IconClock-Dl-xdEIm.js","assets/AgentsTab-DJ-0Trzo.js","assets/PlaybackTab-DX5SA_Uq.js","assets/IconPlayerSkipForwardFilled-DO-WPkWS.js","assets/ContextReplayTab-DjTyR19Y.js","assets/CostTab-BAdmE1EC.js","assets/BudgetsTab-n3gfgUaU.js","assets/CompareTab-CVqkwtRX.js","assets/YieldTab-Ck-altPN.js","assets/IconCircleX-1-WYCT5P.js","assets/ForksTab-ovi_pavq.js","assets/CodingHealthTab-Cz4e1hEF.js","assets/WorktreesTab-B6uk-r9G.js","assets/IconActivity-BkaNd4nH.js"])))=>i.map(i=>d[i]); -import{r as s,j as e,m as H,d as X,u as F,e as Z,_ as c}from"./react-vendor-B7v2HPaI.js";import{h as ee,i as te,j as ae,a as re,f as se,g as oe,L as U,I as M,E as ne,r as ie,k as de,l as ce}from"./index-DDMGE_ZF.js";import{E as le}from"./EmptyState-o0gibvhZ.js";import{F as ue}from"./FilterBar-DuduzIn6.js";import{I as xe,u as me,T as P,B as pe,a as q,D as ge}from"./dashboardTabs-CJWioGLA.js";/** - * @license @tabler/icons-react v3.36.1 - MIT - * - * This source code is licensed under the MIT license. - * See the LICENSE file in the root directory of this source tree. - */const be=[["path",{d:"M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2",key:"svg-0"}],["path",{d:"M7 11l5 5l5 -5",key:"svg-1"}],["path",{d:"M12 4l0 12",key:"svg-2"}]],K=ee("outline","download","Download",be),D="stackunderflow:nav";function f(){return typeof window<"u"&&typeof window.history<"u"}function R(t){f()&&window.dispatchEvent(new CustomEvent(D,{detail:t}))}function S(t,n=[]){if(!f())return"";const r=new URL(window.location.href);for(const b of n)r.searchParams.delete(b);for(const[b,y]of Object.entries(t))r.searchParams.set(b,y);const a=`${r.pathname}${r.search}${r.hash}`,x=`${window.location.pathname}${window.location.search}${window.location.hash}`;return a!==x&&window.history.pushState({},"",a),r.search}function Ge(t){S({tab:"messages",interaction:t},["session"]),R({tab:"messages",interaction:t})}function He(t){S({tab:"sessions",session:t},["interaction"]),R({tab:"sessions",session:t})}function ye(t,n){const r={tab:t,...n??{}};S(r,["session","interaction"]),R(r)}function Q(){return f()?new URLSearchParams(window.location.search).get("tab"):null}function L(t){return f()?new URLSearchParams(window.location.search).get(t):null}function J(t){if(!f())return;const n=new URL(window.location.href);if(!n.searchParams.has(t))return;n.searchParams.delete(t);const r=`${n.pathname}${n.search}${n.hash}`;window.history.replaceState({},"",r)}const he=[{value:"today",label:"Today"},{value:"week",label:"Last 7 days"},{value:"month",label:"Last 30 days"},{value:"all",label:"All time"}],fe=[{value:"csv",label:"CSV"},{value:"json",label:"JSON"}];function we({tab:t,className:n}){const[r,a]=s.useState(!1),[x,b]=s.useState("csv"),[y,w]=s.useState("week"),h=s.useRef(null);s.useEffect(()=>{if(!r)return;const i=u=>{h.current&&(h.current.contains(u.target)||a(!1))},p=u=>{u.key==="Escape"&&a(!1)};return window.addEventListener("mousedown",i),window.addEventListener("keydown",p),()=>{window.removeEventListener("mousedown",i),window.removeEventListener("keydown",p)}},[r]);const v=()=>{const p=`/api/export?${new URLSearchParams({format:x,period:y}).toString()}`,u=document.createElement("a");u.href=p,u.rel="noopener",u.download="",document.body.appendChild(u),u.click(),document.body.removeChild(u),a(!1)};return e.jsxs("div",{ref:h,className:`relative ${n??""}`,children:[e.jsxs("button",{type:"button",onClick:()=>a(i=>!i),"aria-haspopup":"menu","aria-expanded":r,title:`Export ${t} data`,"data-testid":`export-button-${t}`,className:"inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 hover:text-gray-900 dark:hover:text-white",children:[e.jsx(K,{size:13}),"Export",e.jsx(te,{size:11,className:r?"rotate-180 transition-transform":"transition-transform"})]}),r&&e.jsxs("div",{role:"menu",className:"absolute right-0 top-full mt-1 z-20 w-56 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-md shadow-lg p-3 space-y-3","data-testid":`export-popover-${t}`,children:[e.jsxs("div",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"Format"}),e.jsx("div",{className:"flex gap-1",children:fe.map(i=>{const p=x===i.value;return e.jsx("button",{type:"button",onClick:()=>b(i.value),"aria-pressed":p,className:"flex-1 text-xs px-2 py-1 rounded border transition-colors "+(p?"bg-indigo-500/20 border-indigo-500/60 text-indigo-700 dark:text-indigo-200":"bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600"),children:i.label},i.value)})})]}),e.jsxs("div",{children:[e.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"Period"}),e.jsx("select",{value:y,onChange:i=>w(i.target.value),className:"w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-2 py-1 text-xs text-gray-700 dark:text-gray-300 focus:outline-none focus:border-indigo-500","aria-label":"Period",children:he.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})]}),e.jsxs("button",{type:"button",onClick:v,className:"w-full inline-flex items-center justify-center gap-1.5 px-2 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded","data-testid":`export-download-${t}`,children:[e.jsx(K,{size:13}),"Download"]})]})]})}function ve({trail:t}){return t.length===0?null:e.jsx("nav",{"aria-label":"Breadcrumb",className:"flex items-center text-xs text-gray-600 dark:text-gray-400",children:e.jsx("ol",{className:"flex items-center flex-wrap gap-1",children:t.map((n,r)=>{const a=r===t.length-1,x=!a&&typeof n.onClick=="function";return e.jsxs(s.Fragment,{children:[e.jsx("li",{className:"flex items-center",children:x?e.jsx("button",{type:"button",onClick:n.onClick,className:"text-gray-600 dark:text-gray-400 hover:text-indigo-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400 rounded px-0.5",children:n.label}):e.jsx("span",{className:a?"text-gray-800 dark:text-gray-200 font-medium":"text-gray-600 dark:text-gray-400","aria-current":a?"page":void 0,children:n.label})}),!a&&e.jsx("li",{"aria-hidden":"true",className:"flex items-center text-gray-400 dark:text-gray-600",children:e.jsx(ae,{size:12})})]},`${r}-${n.label}`)})})})}function _e(){return e.jsxs("button",{type:"button","aria-label":"Go back",onClick:()=>{typeof window<"u"&&window.history.back()},className:"inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400",children:[e.jsx(xe,{size:12}),"Back"]})}const je=s.lazy(()=>c(()=>import("./OverviewTab-BptDgpTp.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]))),ke=s.lazy(()=>c(()=>import("./CommandsTab-D4oiPDTW.js"),__vite__mapDeps([20,1,21,2,3,22,4,18,19,6]))),Ee=s.lazy(()=>c(()=>import("./MessagesTab-Dcm_kMZs.js"),__vite__mapDeps([23,1,21,2,3,22,24,19,4,25,26,27,28,18,6]))),Te=s.lazy(()=>c(()=>import("./SearchTab-BNg4qG1t.js"),__vite__mapDeps([29,1,2,3,18,30,4,31]))),Ne=s.lazy(()=>c(()=>import("./QATab-Dgj7TjI_.js"),__vite__mapDeps([32,1,2,3,18,30,4,33,34]))),Le=s.lazy(()=>c(()=>import("./BookmarksTab-DAjJ72Pj.js"),__vite__mapDeps([35,1,2,3,18,19,4,24,30,33,6,13]))),Pe=s.lazy(()=>c(()=>import("./TagsTab-CBR6F8Z3.js"),__vite__mapDeps([36,1,2,3,18,5,6]))),De=s.lazy(()=>c(()=>import("./SessionsTab-1seW1KtH.js"),__vite__mapDeps([37,1,2,3,18,25,26,27,28,38,4,19,6,5,15,17,39,34]))),Re=s.lazy(()=>c(()=>import("./AgentsTab-DJ-0Trzo.js"),__vite__mapDeps([40,1,2,3,4,18,6,17,15,5,39]))),Se=s.lazy(()=>c(()=>import("./PlaybackTab-DX5SA_Uq.js"),__vite__mapDeps([41,1,2,3,18,39,12,6,42]))),Ae=s.lazy(()=>c(()=>import("./ContextReplayTab-DjTyR19Y.js"),__vite__mapDeps([43,1,2,3,18,4,42]))),Ie=s.lazy(()=>c(()=>import("./CostTab-BAdmE1EC.js"),__vite__mapDeps([44,1,2,3,4,7,6,5,8,9,10,38,22,19,18]))),Oe=s.lazy(()=>c(()=>import("./BudgetsTab-n3gfgUaU.js"),__vite__mapDeps([45,1,2,3,18,4,6,13,10]))),ze=s.lazy(()=>c(()=>import("./CompareTab-CVqkwtRX.js"),__vite__mapDeps([46,1,2,3,18,28,4,6,31]))),Ce=s.lazy(()=>c(()=>import("./YieldTab-Ck-altPN.js"),__vite__mapDeps([47,1,2,3,18,4,6,48,8]))),$e=s.lazy(()=>c(()=>import("./ForksTab-ovi_pavq.js"),__vite__mapDeps([49,1,2,3,18,4,6,17]))),Be=s.lazy(()=>c(()=>import("./CodingHealthTab-Cz4e1hEF.js"),__vite__mapDeps([50,1,2,3,18,6,12]))),Ve=s.lazy(()=>c(()=>import("./WorktreesTab-B6uk-r9G.js"),__vite__mapDeps([51,1,2,3,18,4,6,52,13,14]))),Fe=P.map(t=>t.id);function W(t){return!!t&&Fe.includes(t)}function Ue(){const t=Q();return W(t)?t:"overview"}function Me(t){if(!t)return[];const n=Object.entries(t).filter(([r])=>r!==""&&r.toLowerCase()!=="unknown").map(([r,a])=>({model:r,count:typeof(a==null?void 0:a.count)=="number"?a.count:0}));return n.sort((r,a)=>a.count-r.count),n.slice(0,6).map(r=>r.model)}function qe(){var $,B;const{name:t}=H(),n=X(),{filters:r}=re(),[a,x]=s.useState(Ue),[b,y]=s.useState(0),{isTabVisible:w,betaEnabled:h,setBetaEnabled:v}=me(),i=s.useMemo(()=>P.filter(o=>w(o.id,o.isBeta??!1)),[w]);s.useEffect(()=>{if(typeof window>"u")return;const o=d=>{if(d.key!==null&&d.key!==q)return;let l=ge;try{const k=window.localStorage.getItem(q);if(k!==null){const V=JSON.parse(k);typeof V=="boolean"&&(l=V)}}catch{}l!==h&&v(l)};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[h,v]),s.useEffect(()=>{i.some(o=>o.id===a)||x("overview")},[i,a]);const{isLoading:p,error:u}=F({queryKey:["setProject",t],queryFn:()=>de(t),enabled:!!t,staleTime:6e4}),{data:g,isLoading:_,isFetching:E,error:T}=F({queryKey:["dashboardData",t,r.providers,r.models],queryFn:()=>ce(new Date().getTimezoneOffset(),{providers:r.providers,models:r.models}),enabled:!!t&&!p,placeholderData:(o,d)=>{var l;return((l=d==null?void 0:d.queryKey)==null?void 0:l[1])===t?o:void 0}}),N=Z({mutationFn:()=>ie(new Date().getTimezoneOffset()),onSuccess:()=>{n.invalidateQueries({queryKey:["dashboardData",t]})}}),Y=s.useCallback(o=>{if(x(o),typeof window>"u")return;const d=new URL(window.location.href);d.searchParams.set("tab",o);const l=`${d.pathname}${d.search}${d.hash}`,k=`${window.location.pathname}${window.location.search}${window.location.hash}`;l!==k&&window.history.replaceState({},"",l)},[]),A=s.useCallback((o,d)=>{x(o),ye(o,d)},[]);s.useEffect(()=>{if(!(typeof window>"u"))return window.__suSwitchTab=A,()=>{delete window.__suSwitchTab}},[A]),s.useEffect(()=>{if(typeof window>"u")return;const o=()=>{const d=Q();W(d)&&x(l=>l===d?l:d),y(l=>l+1)};return window.addEventListener(D,o),window.addEventListener("popstate",o),()=>{window.removeEventListener(D,o),window.removeEventListener("popstate",o)}},[]);const I=t?se(t,void 0,oe()):"";if(p||_)return e.jsx(U,{message:`Loading ${I}...`});if(u||T&&!g){const o=u||T;return e.jsx("div",{className:"p-6",children:e.jsxs("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-400 text-sm",children:["Failed to load project: ",o instanceof Error?o.message:"Unknown error"]})})}if(!g)return e.jsx(le,{title:"No data",description:"No dashboard data available"});const m=g.statistics,G=L("q")??"",O=L("session"),z=L("interaction"),C=(($=P.find(o=>o.id===a))==null?void 0:$.label)??a;let j=null;return O?j=[{label:C,onClick:()=>J("session")},{label:`Session · ${O}`}]:z&&(j=[{label:C,onClick:()=>J("interaction")},{label:`Interaction · ${z}`}]),e.jsxs("div",{className:"max-w-7xl mx-auto px-6 py-4 space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-lg font-bold text-gray-900 dark:text-gray-100",children:I}),((B=m==null?void 0:m.overview)==null?void 0:B.date_range)&&e.jsxs("p",{className:"text-xs text-gray-500",children:[m.overview.date_range.start," — ",m.overview.date_range.end]})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[E&&!_&&e.jsxs("span",{className:"flex items-center gap-1.5 text-xs text-gray-500","aria-live":"polite",children:[e.jsx(M,{size:12,className:"animate-spin"}),"Updating…"]}),T&&g&&e.jsx("span",{className:"text-xs text-red-600 dark:text-red-400",role:"status",children:"Refresh failed"}),g.is_reindexing&&e.jsx("span",{className:"text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 px-2 py-1 rounded",children:"Reindexing..."}),e.jsxs("button",{onClick:()=>N.mutate(),disabled:N.isPending,className:"flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded text-sm border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 disabled:opacity-50",children:[e.jsx(M,{size:14,className:N.isPending?"animate-spin":""}),"Refresh"]})]})]}),e.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800 flex items-center justify-between gap-3",children:[e.jsx("nav",{className:"flex gap-0 -mb-px overflow-x-auto flex-1 min-w-0","data-testid":"dashboard-tabs",children:i.map(o=>{const d=o.icon;return e.jsxs("button",{onClick:()=>Y(o.id),"data-tab":o.id,className:`flex items-center gap-1.5 px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 ${a===o.id?"text-indigo-400 border-indigo-400":"text-gray-600 dark:text-gray-400 border-transparent hover:text-gray-800 dark:hover:text-gray-200 hover:border-gray-400 dark:hover:border-gray-600"}`,children:[e.jsx(d,{size:14}),o.label,o.isBeta===!0&&e.jsx(pe,{className:"ml-1.5"})]},o.id)})}),e.jsx("div",{className:"pb-1.5 flex-shrink-0",children:e.jsx(we,{tab:a})})]}),e.jsx(ue,{modelOptions:Me(m==null?void 0:m.models)}),j&&e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx(_e,{}),e.jsx(ve,{trail:j})]}),e.jsx("div",{"aria-busy":E&&!_,className:`transition-opacity duration-200 ${E&&!_?"opacity-60":""}`,children:e.jsx(ne,{children:e.jsxs(s.Suspense,{fallback:e.jsx(U,{size:"md",message:"Loading..."}),children:[a==="overview"&&e.jsx(je,{stats:m}),a==="cost"&&e.jsx(Ie,{stats:m}),a==="budgets"&&e.jsx(Oe,{}),a==="compare"&&e.jsx(ze,{}),a==="yield"&&e.jsx(Ce,{}),a==="forks"&&e.jsx($e,{projectName:t}),a==="health"&&e.jsx(Be,{projectName:t}),a==="worktrees"&&e.jsx(Ve,{projectName:t}),a==="commands"&&e.jsx(ke,{data:g}),a==="messages"&&e.jsx(Ee,{data:g,projectName:t}),a==="search"&&e.jsx(Te,{projectName:t,initialQuery:G}),a==="qa"&&e.jsx(Ne,{projectName:t}),a==="bookmarks"&&e.jsx(Le,{}),a==="tags"&&e.jsx(Pe,{}),a==="sessions"&&e.jsx(De,{projectName:t,sessionEfficiency:m.session_efficiency}),a==="agents"&&e.jsx(Re,{projectName:t}),a==="playback"&&e.jsx(Se,{projectName:t}),a==="contextreplay"&&e.jsx(Ae,{projectName:t})]})},a)})]})}const Xe=Object.freeze(Object.defineProperty({__proto__:null,default:qe},Symbol.toStringTag,{value:"Module"}));export{K as I,D as N,Xe as P,Ge as a,J as c,L as g,He as o,ye as s}; diff --git a/stackunderflow/static/react/assets/ProviderChip-PiRoWNpI.js b/stackunderflow/static/react/assets/ProviderChip-baymKnS_.js similarity index 70% rename from stackunderflow/static/react/assets/ProviderChip-PiRoWNpI.js rename to stackunderflow/static/react/assets/ProviderChip-baymKnS_.js index e9a0eb9..4bd19fa 100644 --- a/stackunderflow/static/react/assets/ProviderChip-PiRoWNpI.js +++ b/stackunderflow/static/react/assets/ProviderChip-baymKnS_.js @@ -1 +1 @@ -import{j as t}from"./react-vendor-B7v2HPaI.js";import{n as a,m as i,B as n}from"./index-DDMGE_ZF.js";function c({provider:o,size:r="sm"}){const e=a(o),s=i(o);return t.jsx(n,{color:e,size:r,children:s})}export{c as P}; +import{j as t}from"./react-vendor-B7v2HPaI.js";import{n as a,m as i,B as n}from"./index-DQluCO2S.js";function c({provider:o,size:r="sm"}){const e=a(o),s=i(o);return t.jsx(n,{color:e,size:r,children:s})}export{c as P}; diff --git a/stackunderflow/static/react/assets/QATab-Dgj7TjI_.js b/stackunderflow/static/react/assets/QATab-DUs6uRU9.js similarity index 97% rename from stackunderflow/static/react/assets/QATab-Dgj7TjI_.js rename to stackunderflow/static/react/assets/QATab-DUs6uRU9.js index 47dc6f5..24930d8 100644 --- a/stackunderflow/static/react/assets/QATab-Dgj7TjI_.js +++ b/stackunderflow/static/react/assets/QATab-DUs6uRU9.js @@ -1,4 +1,4 @@ -import{r as i,u as j,j as e}from"./react-vendor-B7v2HPaI.js";import{h as k,b as N,I as _,L as w,T as A,j as Q,X as S,Y as M}from"./index-DDMGE_ZF.js";import{E as R}from"./EmptyState-o0gibvhZ.js";import{T as I}from"./TimeAgo-BNE6wf6R.js";import{f as T}from"./format-Co_unrac.js";import{I as C}from"./IconSortDescending-nc0XizMI.js";import{I as P}from"./IconCode-DaQ_eMLh.js";/** +import{r as i,u as j,j as e}from"./react-vendor-B7v2HPaI.js";import{h as k,b as N,I as _,L as w,T as A,j as Q,X as S,Y as M}from"./index-DQluCO2S.js";import{E as R}from"./EmptyState-o0gibvhZ.js";import{T as I}from"./TimeAgo-BNE6wf6R.js";import{f as T}from"./format-Co_unrac.js";import{I as C}from"./IconSortDescending-DunfClYo.js";import{I as P}from"./IconCode-nzK0btv1.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/SearchTab-BNg4qG1t.js b/stackunderflow/static/react/assets/SearchTab-DsaZoPy9.js similarity index 97% rename from stackunderflow/static/react/assets/SearchTab-BNg4qG1t.js rename to stackunderflow/static/react/assets/SearchTab-DsaZoPy9.js index 0d8b3d3..e84614e 100644 --- a/stackunderflow/static/react/assets/SearchTab-BNg4qG1t.js +++ b/stackunderflow/static/react/assets/SearchTab-DsaZoPy9.js @@ -1 +1 @@ -import{r as n,u as v,j as e}from"./react-vendor-B7v2HPaI.js";import{V as w,b as g,I as S,L as E,T as C,j as R,B as z,W as I}from"./index-DDMGE_ZF.js";import{E as y}from"./EmptyState-o0gibvhZ.js";import{T}from"./TimeAgo-BNE6wf6R.js";import{f as q}from"./format-Co_unrac.js";import{I as L}from"./IconAlertCircle-DyBnJSK0.js";const f=20;function M(t,x){const[c,d]=n.useState(t);return n.useEffect(()=>{const r=setTimeout(()=>d(t),x);return()=>clearTimeout(r)},[t,x]),c}function P(t){switch(t){case"user":return"blue";case"assistant":return"green";case"system":return"yellow";case"tool":return"purple";default:return"gray"}}function A({html:t}){const c=t.replace(/<\/?(?!mark\b)[a-z][^>]*>/gi,"").split(/(|<\/mark>)/gi),d=[];let r=!1;for(let o=0;o"){r=!0;continue}if(i.toLowerCase()===""){r=!1;continue}i!==""&&(r?d.push(e.jsx("mark",{className:"bg-yellow-200 dark:bg-yellow-500/30 text-yellow-900 dark:text-yellow-200 rounded-sm px-0.5",children:i},o)):d.push(i))}return e.jsx("span",{className:"text-sm text-gray-700 dark:text-gray-300 leading-relaxed",children:d})}function _({result:t}){return e.jsx("div",{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-800 hover:bg-gray-100/70 dark:hover:bg-gray-800/50 transition-colors",children:e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("div",{className:"mb-1.5",children:t.snippet?e.jsx(A,{html:t.snippet}):e.jsx("p",{className:"text-sm text-gray-700 dark:text-gray-300 leading-relaxed line-clamp-3",children:t.content.length>300?t.content.slice(0,300)+"...":t.content})}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(z,{color:P(t.role),children:t.role}),t.model&&t.model!=="N/A"&&e.jsx("span",{className:"text-[10px] text-gray-500",title:t.model,children:q(t.model)}),e.jsx("span",{className:"text-[10px] text-gray-600 dark:text-gray-400 font-mono truncate max-w-[180px]",children:t.session_id}),e.jsx(T,{timestamp:t.timestamp})]})]}),e.jsx("div",{className:"flex-shrink-0 text-right",children:e.jsx("span",{className:"text-[10px] font-mono text-gray-500",children:t.relevance.toFixed(2)})})]})})}function H({projectName:t,initialQuery:x=""}){const[c,d]=n.useState(x),[r,o]=n.useState(1),[i,p]=n.useState(!1),h=n.useRef(null),l=M(c,300);n.useEffect(()=>{o(1)},[l]),n.useEffect(()=>{var a;(a=h.current)==null||a.focus()},[]);const{data:s,isLoading:b,isError:j,error:u}=v({queryKey:["search",t,l,r],queryFn:()=>I({q:l,project:t,page:r,per_page:f}),enabled:l.length>0}),N=n.useCallback(async()=>{p(!0);try{await w()}finally{p(!1)}},[]),m=s?Math.ceil(s.total/f):0;return e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsxs("div",{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs("div",{className:"relative flex-1",children:[e.jsx(g,{size:16,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),e.jsx("input",{ref:h,type:"text",value:c,onChange:a=>d(a.target.value),placeholder:"Search messages...",className:"w-full pl-9 pr-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-sm text-gray-800 dark:text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-600 focus:ring-1 focus:ring-blue-600 transition-colors"})]}),e.jsxs("button",{onClick:N,disabled:i,className:"flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md hover:text-gray-800 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600 disabled:opacity-50 transition-colors",title:"Reindex search",children:[e.jsx(S,{size:14,className:i?"animate-spin":""}),"Reindex"]})]}),s&&l&&e.jsxs("p",{className:"mt-2 text-xs text-gray-500",children:[s.total," result",s.total!==1?"s":""," for “",s.query,"”"]})]}),e.jsxs("div",{className:"flex-1 overflow-auto",children:[!l&&e.jsx(y,{icon:e.jsx(g,{size:32}),title:"Search messages",description:"Enter a query to search across all messages in this project."}),l&&b&&e.jsx(E,{message:"Searching..."}),j&&e.jsxs("div",{className:"flex flex-col items-center justify-center p-8 text-center",children:[e.jsx(L,{size:32,className:"text-red-400 mb-2"}),e.jsx("p",{className:"text-sm text-red-400 font-medium",children:"Search failed"}),e.jsx("p",{className:"text-xs text-gray-500 mt-1",children:u instanceof Error?u.message:"An unexpected error occurred"})]}),s&&s.results.length===0&&e.jsx(y,{icon:e.jsx(g,{size:32}),title:"No results found",description:`No messages match "${l}". Try a different search term.`}),s&&s.results.length>0&&e.jsx("div",{children:s.results.map((a,k)=>e.jsx(_,{result:a},`${a.session_id}-${k}`))})]}),s&&m>1&&e.jsxs("div",{className:"flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-800",children:[e.jsxs("button",{onClick:()=>o(a=>Math.max(1,a-1)),disabled:r<=1,className:"flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",children:[e.jsx(C,{size:14}),"Prev"]}),e.jsxs("span",{className:"text-xs text-gray-500",children:["Page ",r," of ",m]}),e.jsxs("button",{onClick:()=>o(a=>Math.min(m,a+1)),disabled:r>=m,className:"flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",children:["Next",e.jsx(R,{size:14})]})]})]})}export{H as default}; +import{r as n,u as v,j as e}from"./react-vendor-B7v2HPaI.js";import{V as w,b as g,I as S,L as E,T as C,j as R,B as z,W as I}from"./index-DQluCO2S.js";import{E as y}from"./EmptyState-o0gibvhZ.js";import{T}from"./TimeAgo-BNE6wf6R.js";import{f as q}from"./format-Co_unrac.js";import{I as L}from"./IconAlertCircle-h2zR9MWj.js";const f=20;function M(t,x){const[c,d]=n.useState(t);return n.useEffect(()=>{const r=setTimeout(()=>d(t),x);return()=>clearTimeout(r)},[t,x]),c}function P(t){switch(t){case"user":return"blue";case"assistant":return"green";case"system":return"yellow";case"tool":return"purple";default:return"gray"}}function A({html:t}){const c=t.replace(/<\/?(?!mark\b)[a-z][^>]*>/gi,"").split(/(|<\/mark>)/gi),d=[];let r=!1;for(let o=0;o"){r=!0;continue}if(i.toLowerCase()===""){r=!1;continue}i!==""&&(r?d.push(e.jsx("mark",{className:"bg-yellow-200 dark:bg-yellow-500/30 text-yellow-900 dark:text-yellow-200 rounded-sm px-0.5",children:i},o)):d.push(i))}return e.jsx("span",{className:"text-sm text-gray-700 dark:text-gray-300 leading-relaxed",children:d})}function _({result:t}){return e.jsx("div",{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-800 hover:bg-gray-100/70 dark:hover:bg-gray-800/50 transition-colors",children:e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("div",{className:"mb-1.5",children:t.snippet?e.jsx(A,{html:t.snippet}):e.jsx("p",{className:"text-sm text-gray-700 dark:text-gray-300 leading-relaxed line-clamp-3",children:t.content.length>300?t.content.slice(0,300)+"...":t.content})}),e.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[e.jsx(z,{color:P(t.role),children:t.role}),t.model&&t.model!=="N/A"&&e.jsx("span",{className:"text-[10px] text-gray-500",title:t.model,children:q(t.model)}),e.jsx("span",{className:"text-[10px] text-gray-600 dark:text-gray-400 font-mono truncate max-w-[180px]",children:t.session_id}),e.jsx(T,{timestamp:t.timestamp})]})]}),e.jsx("div",{className:"flex-shrink-0 text-right",children:e.jsx("span",{className:"text-[10px] font-mono text-gray-500",children:t.relevance.toFixed(2)})})]})})}function H({projectName:t,initialQuery:x=""}){const[c,d]=n.useState(x),[r,o]=n.useState(1),[i,p]=n.useState(!1),h=n.useRef(null),l=M(c,300);n.useEffect(()=>{o(1)},[l]),n.useEffect(()=>{var a;(a=h.current)==null||a.focus()},[]);const{data:s,isLoading:b,isError:j,error:u}=v({queryKey:["search",t,l,r],queryFn:()=>I({q:l,project:t,page:r,per_page:f}),enabled:l.length>0}),N=n.useCallback(async()=>{p(!0);try{await w()}finally{p(!1)}},[]),m=s?Math.ceil(s.total/f):0;return e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsxs("div",{className:"px-4 py-3 border-b border-gray-200 dark:border-gray-800",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs("div",{className:"relative flex-1",children:[e.jsx(g,{size:16,className:"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"}),e.jsx("input",{ref:h,type:"text",value:c,onChange:a=>d(a.target.value),placeholder:"Search messages...",className:"w-full pl-9 pr-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-sm text-gray-800 dark:text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-600 focus:ring-1 focus:ring-blue-600 transition-colors"})]}),e.jsxs("button",{onClick:N,disabled:i,className:"flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md hover:text-gray-800 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600 disabled:opacity-50 transition-colors",title:"Reindex search",children:[e.jsx(S,{size:14,className:i?"animate-spin":""}),"Reindex"]})]}),s&&l&&e.jsxs("p",{className:"mt-2 text-xs text-gray-500",children:[s.total," result",s.total!==1?"s":""," for “",s.query,"”"]})]}),e.jsxs("div",{className:"flex-1 overflow-auto",children:[!l&&e.jsx(y,{icon:e.jsx(g,{size:32}),title:"Search messages",description:"Enter a query to search across all messages in this project."}),l&&b&&e.jsx(E,{message:"Searching..."}),j&&e.jsxs("div",{className:"flex flex-col items-center justify-center p-8 text-center",children:[e.jsx(L,{size:32,className:"text-red-400 mb-2"}),e.jsx("p",{className:"text-sm text-red-400 font-medium",children:"Search failed"}),e.jsx("p",{className:"text-xs text-gray-500 mt-1",children:u instanceof Error?u.message:"An unexpected error occurred"})]}),s&&s.results.length===0&&e.jsx(y,{icon:e.jsx(g,{size:32}),title:"No results found",description:`No messages match "${l}". Try a different search term.`}),s&&s.results.length>0&&e.jsx("div",{children:s.results.map((a,k)=>e.jsx(_,{result:a},`${a.session_id}-${k}`))})]}),s&&m>1&&e.jsxs("div",{className:"flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-800",children:[e.jsxs("button",{onClick:()=>o(a=>Math.max(1,a-1)),disabled:r<=1,className:"flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",children:[e.jsx(C,{size:14}),"Prev"]}),e.jsxs("span",{className:"text-xs text-gray-500",children:["Page ",r," of ",m]}),e.jsxs("button",{onClick:()=>o(a=>Math.min(m,a+1)),disabled:r>=m,className:"flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors",children:["Next",e.jsx(R,{size:14})]})]})]})}export{H as default}; diff --git a/stackunderflow/static/react/assets/SessionsTab-1seW1KtH.js b/stackunderflow/static/react/assets/SessionsTab-NrDPSNfB.js similarity index 98% rename from stackunderflow/static/react/assets/SessionsTab-1seW1KtH.js rename to stackunderflow/static/react/assets/SessionsTab-NrDPSNfB.js index 1f54684..3519e0f 100644 --- a/stackunderflow/static/react/assets/SessionsTab-1seW1KtH.js +++ b/stackunderflow/static/react/assets/SessionsTab-NrDPSNfB.js @@ -1,4 +1,4 @@ -import{r as n,j as e,u as D}from"./react-vendor-B7v2HPaI.js";import{h as oe,B as Ne,i as ve,u as we,I as Se,a as _e,L as U,b as Ce,T as $e,j as Me,N as ie,a3 as Ee,a4 as Ie,k as Fe}from"./index-DDMGE_ZF.js";import{E as le}from"./EmptyState-o0gibvhZ.js";import Le from"./Markdown-Be0CkqQs.js";import{P as Re}from"./ProviderChip-PiRoWNpI.js";import{I as ze,E as De}from"./EstimatedCostMarker-DkSj8xDc.js";import{o as Y,g as Q,c as W,N as G}from"./ProjectDashboard-DRHQbkve.js";import{b as Ke,f as de}from"./format-Co_unrac.js";import{I as X}from"./FilterBar-DuduzIn6.js";import{I as ce}from"./dashboardTabs-CJWioGLA.js";import{I as Te}from"./IconHash-Bsu2htWI.js";import{I as xe,a as R}from"./IconUser-ectHbzBi.js";import{I as ue}from"./IconRobot-BQwymFKA.js";import{I as ge}from"./IconClock-Dl-xdEIm.js";import{I as me}from"./IconCode-DaQ_eMLh.js";import"./syntax-highlighter-BYF-y4SB.js";import"./markdown-DgJRk3H5.js";/** +import{r as n,j as e,u as D}from"./react-vendor-B7v2HPaI.js";import{h as oe,B as Ne,i as ve,u as we,I as Se,a as _e,L as U,b as Ce,T as $e,j as Me,N as ie,a3 as Ee,a4 as Ie,k as Fe}from"./index-DQluCO2S.js";import{E as le}from"./EmptyState-o0gibvhZ.js";import Le from"./Markdown-Be0CkqQs.js";import{P as Re}from"./ProviderChip-baymKnS_.js";import{I as ze,E as De}from"./EstimatedCostMarker-BpsBXEjf.js";import{o as Y,g as Q,c as W,N as G}from"./ProjectDashboard-DAsn11K-.js";import{b as Ke,f as de}from"./format-Co_unrac.js";import{I as X}from"./FilterBar-BS6lhcC1.js";import{I as ce}from"./dashboardTabs-C5ecR5YO.js";import{I as Te}from"./IconHash-cdeuBxnj.js";import{I as xe,a as R}from"./IconUser-IjRNaThB.js";import{I as ue}from"./IconRobot-DuCKb11z.js";import{I as ge}from"./IconClock-CgB7iTL4.js";import{I as me}from"./IconCode-nzK0btv1.js";import"./syntax-highlighter-BYF-y4SB.js";import"./markdown-DgJRk3H5.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/Settings-Cnnpq-i1.js b/stackunderflow/static/react/assets/Settings-BSujxVUW.js similarity index 99% rename from stackunderflow/static/react/assets/Settings-Cnnpq-i1.js rename to stackunderflow/static/react/assets/Settings-BSujxVUW.js index c997561..3605148 100644 --- a/stackunderflow/static/react/assets/Settings-Cnnpq-i1.js +++ b/stackunderflow/static/react/assets/Settings-BSujxVUW.js @@ -1,4 +1,4 @@ -import{u as j,j as e,L as R,r as h,d as B,e as w}from"./react-vendor-B7v2HPaI.js";import{h as A,u as I,q as F,t as M,v as O,w as D,x as P,y as L,z as q,A as z,C as K,D as N,F as U,I as C,G as $,p as H,H as Q,J,K as Y}from"./index-DDMGE_ZF.js";import{u as W,I as G,T as Z,B as V,b as X}from"./dashboardTabs-CJWioGLA.js";import{c as S,b as _}from"./format-Co_unrac.js";import{I as ee}from"./IconAlertCircle-DyBnJSK0.js";import{I as E}from"./IconArrowRight-xIaEMpRY.js";import{I as re}from"./IconCircleX-1-WYCT5P.js";/** +import{u as j,j as e,L as R,r as h,d as B,e as w}from"./react-vendor-B7v2HPaI.js";import{h as A,u as I,q as F,t as M,v as O,w as D,x as P,y as L,z as q,A as z,C as K,D as N,F as U,I as C,G as $,p as H,H as Q,J,K as Y}from"./index-DQluCO2S.js";import{u as W,I as G,T as Z,B as V,b as X}from"./dashboardTabs-C5ecR5YO.js";import{c as S,b as _}from"./format-Co_unrac.js";import{I as ee}from"./IconAlertCircle-h2zR9MWj.js";import{I as E}from"./IconArrowRight-lOOgkPXu.js";import{I as re}from"./IconCircleX-D7ABaoUn.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/TagsTab-CBR6F8Z3.js b/stackunderflow/static/react/assets/TagsTab-DSK8Zu2d.js similarity index 97% rename from stackunderflow/static/react/assets/TagsTab-CBR6F8Z3.js rename to stackunderflow/static/react/assets/TagsTab-DSK8Zu2d.js index 1306568..08ac2ad 100644 --- a/stackunderflow/static/react/assets/TagsTab-CBR6F8Z3.js +++ b/stackunderflow/static/react/assets/TagsTab-DSK8Zu2d.js @@ -1,4 +1,4 @@ -import{d as j,r as m,u,e as v,j as e}from"./react-vendor-B7v2HPaI.js";import{h as k,a0 as y,L as p,b as N,I as w,a1 as C,a2 as S}from"./index-DDMGE_ZF.js";import{E as b}from"./EmptyState-o0gibvhZ.js";import{I as L}from"./IconHash-Bsu2htWI.js";import{g as h,I as T}from"./dashboardTabs-CJWioGLA.js";/** +import{d as j,r as m,u,e as v,j as e}from"./react-vendor-B7v2HPaI.js";import{h as k,a0 as y,L as p,b as N,I as w,a1 as C,a2 as S}from"./index-DQluCO2S.js";import{E as b}from"./EmptyState-o0gibvhZ.js";import{I as L}from"./IconHash-cdeuBxnj.js";import{g as h,I as T}from"./dashboardTabs-C5ecR5YO.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/TokenCompositionDonut-DsJdUSqI.js b/stackunderflow/static/react/assets/TokenCompositionDonut-DTEMWIwY.js similarity index 98% rename from stackunderflow/static/react/assets/TokenCompositionDonut-DsJdUSqI.js rename to stackunderflow/static/react/assets/TokenCompositionDonut-DTEMWIwY.js index 63e2c92..edb3a18 100644 --- a/stackunderflow/static/react/assets/TokenCompositionDonut-DsJdUSqI.js +++ b/stackunderflow/static/react/assets/TokenCompositionDonut-DTEMWIwY.js @@ -1,4 +1,4 @@ -import{h as y,p as F,N as R,u as D,G as A,j as E}from"./index-DDMGE_ZF.js";import{j as e,r as f}from"./react-vendor-B7v2HPaI.js";import{b as L}from"./dashboardTabs-CJWioGLA.js";import{I as z}from"./IconHash-Bsu2htWI.js";import{I as B}from"./IconTrendingUp-DvwC6rkL.js";import{c as j,b as N,a as b}from"./format-Co_unrac.js";import{u as M}from"./chartTheme-GpkpBpop.js";import{R as I,h as O,T,f as P,P as K,c as Y,d as H,i as W,L as U,S as G}from"./recharts-C8DDeE7E.js";/** +import{h as y,p as F,N as R,u as D,G as A,j as E}from"./index-DQluCO2S.js";import{j as e,r as f}from"./react-vendor-B7v2HPaI.js";import{b as L}from"./dashboardTabs-C5ecR5YO.js";import{I as z}from"./IconHash-cdeuBxnj.js";import{I as B}from"./IconTrendingUp-D1iuYAG9.js";import{c as j,b as N,a as b}from"./format-Co_unrac.js";import{u as M}from"./chartTheme-GpkpBpop.js";import{R as I,h as O,T,f as P,P as K,c as Y,d as H,i as W,L as U,S as G}from"./recharts-C8DDeE7E.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/WorktreesTab-B6uk-r9G.js b/stackunderflow/static/react/assets/WorktreesTab-B65wyswE.js similarity index 98% rename from stackunderflow/static/react/assets/WorktreesTab-B6uk-r9G.js rename to stackunderflow/static/react/assets/WorktreesTab-B65wyswE.js index 0c080ef..d518754 100644 --- a/stackunderflow/static/react/assets/WorktreesTab-B6uk-r9G.js +++ b/stackunderflow/static/react/assets/WorktreesTab-B65wyswE.js @@ -1,4 +1,4 @@ -import{d as E,r as f,u as b,e as T,j as e}from"./react-vendor-B7v2HPaI.js";import{h as z,u as A,I,L as P,z as q,p as j,B as k,i as M,j as R,k as $}from"./index-DDMGE_ZF.js";import{E as F}from"./EmptyState-o0gibvhZ.js";import{b as w}from"./format-Co_unrac.js";import{o as u,b as L,c as D}from"./dashboardTabs-CJWioGLA.js";import{I as U}from"./IconActivity-BkaNd4nH.js";import{I as W}from"./IconCheck-DwMBTi_m.js";import{I as B}from"./IconCopy-DA8lvnPB.js";/** +import{d as E,r as f,u as b,e as T,j as e}from"./react-vendor-B7v2HPaI.js";import{h as z,u as A,I,L as P,z as q,p as j,B as k,i as M,j as R,k as $}from"./index-DQluCO2S.js";import{E as F}from"./EmptyState-o0gibvhZ.js";import{b as w}from"./format-Co_unrac.js";import{o as u,b as L,c as D}from"./dashboardTabs-C5ecR5YO.js";import{I as U}from"./IconActivity-Bf5MQINx.js";import{I as W}from"./IconCheck-BQLu3HFV.js";import{I as B}from"./IconCopy-BOYWZx4M.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/YieldTab-Ck-altPN.js b/stackunderflow/static/react/assets/YieldTab-BbHHGYlL.js similarity index 97% rename from stackunderflow/static/react/assets/YieldTab-Ck-altPN.js rename to stackunderflow/static/react/assets/YieldTab-BbHHGYlL.js index 7528a84..83f5b40 100644 --- a/stackunderflow/static/react/assets/YieldTab-Ck-altPN.js +++ b/stackunderflow/static/react/assets/YieldTab-BbHHGYlL.js @@ -1,4 +1,4 @@ -import{r as g,u as p,j as e}from"./react-vendor-B7v2HPaI.js";import{h as u,u as y,a as h,L as b,G as f,p as j,B as w,am as k}from"./index-DDMGE_ZF.js";import{E as N}from"./EmptyState-o0gibvhZ.js";import{b as m}from"./format-Co_unrac.js";import{m as x}from"./dashboardTabs-CJWioGLA.js";import{I as v}from"./IconCircleX-1-WYCT5P.js";import{I as _}from"./IconTrendingUp-DvwC6rkL.js";/** +import{r as g,u as p,j as e}from"./react-vendor-B7v2HPaI.js";import{h as u,u as y,a as h,L as b,G as f,p as j,B as w,am as k}from"./index-DQluCO2S.js";import{E as N}from"./EmptyState-o0gibvhZ.js";import{b as m}from"./format-Co_unrac.js";import{m as x}from"./dashboardTabs-C5ecR5YO.js";import{I as v}from"./IconCircleX-D7ABaoUn.js";import{I as _}from"./IconTrendingUp-D1iuYAG9.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/dashboardTabs-CJWioGLA.js b/stackunderflow/static/react/assets/dashboardTabs-C5ecR5YO.js similarity index 99% rename from stackunderflow/static/react/assets/dashboardTabs-CJWioGLA.js rename to stackunderflow/static/react/assets/dashboardTabs-C5ecR5YO.js index 9caa976..6220f7d 100644 --- a/stackunderflow/static/react/assets/dashboardTabs-CJWioGLA.js +++ b/stackunderflow/static/react/assets/dashboardTabs-C5ecR5YO.js @@ -1,4 +1,4 @@ -import{h as e,M,b}from"./index-DDMGE_ZF.js";import{r as c,j as f}from"./react-vendor-B7v2HPaI.js";/** +import{h as e,M,b}from"./index-DQluCO2S.js";import{r as c,j as f}from"./react-vendor-B7v2HPaI.js";/** * @license @tabler/icons-react v3.36.1 - MIT * * This source code is licensed under the MIT license. diff --git a/stackunderflow/static/react/assets/index-DDMGE_ZF.js b/stackunderflow/static/react/assets/index-DQluCO2S.js similarity index 92% rename from stackunderflow/static/react/assets/index-DDMGE_ZF.js rename to stackunderflow/static/react/assets/index-DQluCO2S.js index ba90f21..f4996c4 100644 --- a/stackunderflow/static/react/assets/index-DDMGE_ZF.js +++ b/stackunderflow/static/react/assets/index-DQluCO2S.js @@ -1,4 +1,4 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Markdown-Be0CkqQs.js","assets/react-vendor-B7v2HPaI.js","assets/syntax-highlighter-BYF-y4SB.js","assets/markdown-DgJRk3H5.js","assets/Overview-DRVBiWsj.js","assets/EmptyState-o0gibvhZ.js","assets/ProviderChip-PiRoWNpI.js","assets/format-Co_unrac.js","assets/FilterBar-DuduzIn6.js","assets/IconArrowUp-CszCUTPq.js","assets/ProjectDashboard-DRHQbkve.js","assets/dashboardTabs-CJWioGLA.js","assets/Live-B7kSsFN1.js","assets/IconClockHour4-UHU_zj__.js","assets/IconActivity-BkaNd4nH.js","assets/Settings-Cnnpq-i1.js","assets/IconAlertCircle-DyBnJSK0.js","assets/IconArrowRight-xIaEMpRY.js","assets/IconCircleX-1-WYCT5P.js"])))=>i.map(i=>d[i]); +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Markdown-Be0CkqQs.js","assets/react-vendor-B7v2HPaI.js","assets/syntax-highlighter-BYF-y4SB.js","assets/markdown-DgJRk3H5.js","assets/Overview-BqyHxuZ_.js","assets/EmptyState-o0gibvhZ.js","assets/ProviderChip-baymKnS_.js","assets/format-Co_unrac.js","assets/FilterBar-BS6lhcC1.js","assets/IconArrowUp-D8NJ3QQF.js","assets/ProjectDashboard-DAsn11K-.js","assets/dashboardTabs-C5ecR5YO.js","assets/Live-BX6jEGZn.js","assets/IconBolt-CWu2Wz0a.js","assets/IconClockHour4-BUN27e3Z.js","assets/IconActivity-Bf5MQINx.js","assets/Settings-BSujxVUW.js","assets/IconAlertCircle-h2zR9MWj.js","assets/IconArrowRight-lOOgkPXu.js","assets/IconCircleX-D7ABaoUn.js"])))=>i.map(i=>d[i]); var Ae=Object.defineProperty;var Oe=(e,t,a)=>t in e?Ae(e,t,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[t]=a;var ee=(e,t,a)=>Oe(e,typeof t!="symbol"?t+"":t,a);import{r as o,j as r,u as le,a as De,b as Se,L as D,_ as U,d as Ue,e as ze,B as Fe,f as Be,h as F,Q as Je,i as Ge,R as Qe,k as We}from"./react-vendor-B7v2HPaI.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&s(l)}).observe(document,{childList:!0,subtree:!0});function a(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerPolicy&&(i.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?i.credentials="include":n.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(n){if(n.ep)return;n.ep=!0;const i=a(n);fetch(n.href,i)}})();/** * @license @tabler/icons-react v3.36.1 - MIT * @@ -102,4 +102,4 @@ var Ae=Object.defineProperty;var Oe=(e,t,a)=>t in e?Ae(e,t,{enumerable:!0,config */const St=[["path",{d:"M4 7l16 0",key:"svg-0"}],["path",{d:"M10 11l0 6",key:"svg-1"}],["path",{d:"M14 11l0 6",key:"svg-2"}],["path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",key:"svg-3"}],["path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",key:"svg-4"}]],Nt=S("outline","trash","Trash",St),x="/api";async function y(e,t){const a=await fetch(e,t);if(!a.ok){const s=await a.text().catch(()=>"");throw new Error(`${a.status} ${a.statusText}${s?`: ${s}`:""}`)}return a.json()}function T(e,t){if(t!=null&&t.providers)for(const a of t.providers)a&&a.trim()&&e.append("provider",a.toLowerCase().trim());if(t!=null&&t.models)for(const a of t.models)a&&a.trim()&&e.append("model",a.toLowerCase().trim());return e}async function $t(e=!1,t,a){const s=new URLSearchParams({include_stats:String(e)});return typeof(a==null?void 0:a.limit)=="number"&&s.set("limit",String(a.limit)),typeof(a==null?void 0:a.offset)=="number"&&s.set("offset",String(a.offset)),T(s,t),y(`${x}/projects?${s}`)}async function Fr(){return y(`${x}/providers`)}async function Br(e){return y(`${x}/project-by-dir`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir_name:e})})}async function Jr(e=0,t){const a=new URLSearchParams({timezone_offset:String(e)});return T(a,t),y(`${x}/dashboard-data?${a}`)}async function Gr(e,t){const a=new URLSearchParams;typeof(e==null?void 0:e.page)=="number"&&a.set("page",String(e.page)),a.set("per_page",String(e.perPage)),T(a,t);const s=a.toString();return y(`${x}/messages${s?`?${s}`:""}`)}async function Qr(e,t){const a=new URLSearchParams;e&&a.set("project",e),T(a,t);const s=a.toString();return y(`${x}/jsonl-files${s?`?${s}`:""}`)}async function Wr(e,t){const a=new URLSearchParams({file:e});return t&&a.set("project",t),y(`${x}/jsonl-content?${a}`)}async function qr(e){const t=new URLSearchParams;return e.project&&t.set("project",e.project),e.date_from&&t.set("date_from",e.date_from),e.date_to&&t.set("date_to",e.date_to),e.search&&t.set("search",e.search),e.resolution_status&&t.set("resolution_status",e.resolution_status),e.page&&t.set("page",String(e.page)),t.set("per_page",String(e.per_page)),y(`${x}/qa?${t}`)}async function Kr(e){const t=new URLSearchParams;return t.set("q",e.q),e.project&&t.set("project",e.project),e.date_from&&t.set("date_from",e.date_from),e.date_to&&t.set("date_to",e.date_to),e.model&&t.set("model",e.model),e.role&&t.set("role",e.role),e.page&&t.set("page",String(e.page)),t.set("per_page",String(e.per_page)),y(`${x}/search?${t}`)}async function Hr(){return y(`${x}/tags`)}async function Vr(e){return y(`${x}/tags/browse/${encodeURIComponent(e)}`)}async function Yr(e,t="created_at"){const a=new URLSearchParams({sort_by:t});return e&&a.set("tag",e),y(`${x}/bookmarks?${a}`)}async function Xr(e){return y(`${x}/bookmarks/${encodeURIComponent(e)}`,{method:"DELETE"})}async function Zr(e,t){return y(`${x}/bookmarks/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}async function ea(e=0){return y(`${x}/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({timezone_offset:e})})}async function ta(){return y(`${x}/global-stats`)}async function ra(e){const a=new URLSearchParams().toString();return y(`${x}/commands/daily${a?`?${a}`:""}`)}function aa(e,t){const a=new URLSearchParams;for(const n of e)n&&n.trim()&&a.append("model",n.toLowerCase().trim());(t==="7d"||t==="30d")&&a.set("range",t);const s=a.toString();return s?`?${s}`:""}async function sa(){return y(`${x}/search/reindex`,{method:"POST"})}async function na(){return y(`${x}/qa/reindex`,{method:"POST"})}async function oa(){return y(`${x}/tags/reindex`,{method:"POST"})}async function ia(){return y(`${x}/pricing`)}async function _t(){return y(`${x}/cfg`)}async function la(){return y(`${x}/cfg/currencies`)}async function Ct(e){return y(`${x}/cfg/currency`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e})})}async function ca(){return y(`${x}/cfg/model-aliases`)}async function da(e,t){return y(`${x}/cfg/model-aliases`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({from:e,to:t})})}async function ua(e){return y(`${x}/cfg/model-aliases?from=${encodeURIComponent(e)}`,{method:"DELETE"})}async function ga(e="month",t){const a=new URLSearchParams({period:e});if(t!=null&&t.providers&&t.providers.length===1){const s=t.providers[0];s&&a.set("provider",s.toLowerCase())}return y(`${x}/compare?${a}`)}async function ma(e="month",t){const a=new URLSearchParams({period:e});return T(a,t),y(`${x}/cost-data/by-provider?${a}`)}async function ha(e="week",t){const a=new URLSearchParams({period:e});return T(a,t),y(`${x}/yield?${a}`)}async function xa(e="all"){const t=new URLSearchParams({period:e});return y(`${x}/forks?${t}`)}async function fa(e="all"){const t=new URLSearchParams({period:e});return y(`${x}/benchmark?${t}`)}async function pa(){return y(`${x}/plan`)}function ue(){return-new Date().getTimezoneOffset()}async function ya(){const e=new URLSearchParams({timezone_offset:String(ue())});return y(`${x}/budgets?${e}`)}async function ba(e){const t=new URLSearchParams({timezone_offset:String(ue())});return y(`${x}/budgets?${t}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}async function ka(){const e=new URLSearchParams({timezone_offset:String(ue())});return y(`${x}/budgets?${e}`,{method:"DELETE"})}async function wa(){return y(`${x}/whatif`)}async function va(e="month",t){const a=new URLSearchParams({period:e});return T(a,t),y(`${x}/optimize?${a}`)}async function ja(){return y(`${x}/context-budget`)}class G extends Error{constructor(t="ETL pipeline route not available"){super(t),this.name="EtlPipelineNotReadyError"}}class Et extends Error{constructor(a){super(`A backfill is already running (job ${a.slice(0,8)}…)`);ee(this,"jobId");this.name="EtlBackfillInProgressError",this.jobId=a}}async function Lt(){const e=await fetch(`${x}/etl/status`);if(e.status===404)throw new G;if(!e.ok){const t=await e.text().catch(()=>"");throw new Error(`${e.status} ${e.statusText}${t?`: ${t}`:""}`)}return e.json()}async function Sa(e=!1){const t=await fetch(`${x}/etl/backfill`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({force:e})});if(t.status===404)throw new G("Backfill route not available — run `stackunderflow etl backfill` from the CLI instead.");if(t.status===409){let a="unknown";try{const s=await t.json();typeof(s==null?void 0:s.job_id)=="string"&&s.job_id.length>0&&(a=s.job_id)}catch{}throw new Et(a)}if(!t.ok){const a=await t.text().catch(()=>"");throw new Error(`${t.status} ${t.statusText}${a?`: ${a}`:""}`)}return t.json()}function oe(e){if(e==null||!Number.isFinite(e)||e<0)return"—";const t=Math.floor(e);if(t<60)return`${t}s`;const a=Math.floor(t/60);if(a<60)return`${a}m`;const s=Math.floor(a/60);return s<24?`${s}h`:`${Math.floor(s/24)}d`}function Tt(e){switch(e){case"live":return{badge:"green",dot:"bg-green-500",pulse:!1};case"syncing":return{badge:"blue",dot:"bg-blue-500",pulse:!0};case"stale":return{badge:"yellow",dot:"bg-yellow-500",pulse:!1};case"error":return{badge:"red",dot:"bg-red-500",pulse:!1}}}function Pt(e){const{health:t,lag_seconds:a,watcher:s,events:n,last_job:i}=e;switch(t){case"live":return`Live (synced ${oe(s.seconds_since_refresh??a)} ago)`;case"syncing":{const l=s.events_in_last_cycle;return`Syncing (${l} event${l===1?"":"s"} behind)`}case"stale":return`Stale by ${oe(a)}`;case"error":return(i==null?void 0:i.status)==="failed"?`Backfill failed (job ${i.job_id.slice(0,8)}…)`:"ETL error — see /etl/status"}return`Unknown (${n.total} events)`}async function Na(e=50,t){const a=new URLSearchParams({limit:String(e)});return t&&a.set("project",t),y(`${x}/agent-teams?${a.toString()}`)}async function $a(e){return y(`${x}/agent-teams/${encodeURIComponent(e)}`)}function _a(e){const t=new URLSearchParams(e),a=t.get("session"),s=t.get("agent");return{session:a&&a.length>0?a:null,agent:s&&s.length>0?s:null}}function Ca(e,t){const a=new URLSearchParams(e);t.session?a.set("session",t.session):a.delete("session"),t.agent?a.set("agent",t.agent):a.delete("agent");const s=a.toString();return s.length>0?`?${s}`:""}function Rt(e){const t=new URLSearchParams;return e!=null&&e.toolFilter&&e.toolFilter.length>0&&t.set("tool_filter",e.toolFilter.join(",")),typeof(e==null?void 0:e.limit)=="number"&&t.set("limit",String(e.limit)),t.set("include_payload","1"),t}async function Ea(e,t){const a=Rt(t).toString();return y(`${x}/playback/${encodeURIComponent(e)}${a?`?${a}`:""}`)}class Mt extends Error{constructor(t){super(`Unparseable timestamp: ${t}`),this.name="PlaybackFsBadTimestampError"}}async function La(e,t){const a=new URLSearchParams({at:t.at});t.paths&&t.paths.length>0&&a.set("paths",t.paths.join(",")),typeof t.includeContent=="boolean"&&a.set("include_content",t.includeContent?"true":"false");const s=await fetch(`${x}/playback/${encodeURIComponent(e)}/fs?${a.toString()}`);if(s.status===422){const n=await s.text().catch(()=>"");throw new Mt(n||t.at)}if(!s.ok){const n=await s.text().catch(()=>"");throw new Error(`${s.status} ${s.statusText}${n?`: ${n}`:""}`)}return s.json()}async function Ta(e,t){const s=new URLSearchParams().toString();return y(`${x}/context-replay/${encodeURIComponent(e)}${s?`?${s}`:""}`)}function It(e){if(e===null||e.trim()==="")return null;const t=Number(e);return!Number.isInteger(t)||t<0?null:t}function Pa(e){const t=new URLSearchParams(e),a=t.get("session");return{session:a&&a.length>0?a:null,seq:It(t.get("seq"))}}async function Ra(e,t="30days"){const a=new URLSearchParams({period:t});return y(`${x}/optimize/prescriptions?${a}`)}const $e="su-name-mode";function _e(){const e=localStorage.getItem($e);return e==="name"||e==="path"||e==="anon"?e:"name"}function Ma(e){localStorage.setItem($e,e),window.dispatchEvent(new Event("namemode-changed"))}function J(e,t,a){const s=a??_e();if(s==="anon")return`Project ${(t??0)+1}`;if(s==="path")return e.startsWith("-")?"/"+e.slice(1).replace(/-/g,"/"):e.replace(/-/g,"/");const i=(e.startsWith("-")?e.slice(1):e).split("-");let l=-1;for(let m=i.length-1;m>=0;m--)if(/^(year|dev|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\d{0,2}$/i.test(i[m]??"")){l=m;break}return l>=0&&lxe());o.useEffect(()=>{Bt(t)},[t]),o.useEffect(()=>{if(!Gt())return;const d=()=>{const c=xe();a(g=>g.providers.length===c.providers.length&&g.providers.every((u,k)=>u===c.providers[k])&&g.models.length===c.models.length&&g.models.every((u,k)=>u===c.models[k])?g:c)};return window.addEventListener("popstate",d),()=>window.removeEventListener("popstate",d)},[]);const s=o.useCallback(d=>{a(c=>{const g=q(d);return c.providers.length===g.length&&c.providers.every((u,k)=>u===g[k])?c:{...c,providers:g}})},[]),n=o.useCallback(d=>{a(c=>{const g=q(d);return c.models.length===g.length&&c.models.every((u,k)=>u===g[k])?c:{...c,models:g}})},[]),i=o.useCallback(d=>{const c=d.toLowerCase().trim();c&&a(g=>g.providers.includes(c)?g:{...g,providers:[...g.providers,c]})},[]),l=o.useCallback(d=>{const c=d.toLowerCase().trim();c&&a(g=>g.models.includes(c)?g:{...g,models:[...g.models,c]})},[]),m=o.useCallback(d=>{const c=d.toLowerCase().trim();a(g=>g.providers.includes(c)?{...g,providers:g.providers.filter(u=>u!==c)}:g)},[]),h=o.useCallback(d=>{const c=d.toLowerCase().trim();a(g=>g.models.includes(c)?{...g,models:g.models.filter(u=>u!==c)}:g)},[]),p=o.useCallback(()=>{a(d=>d.providers.length===0&&d.models.length===0?d:{providers:[],models:[]})},[]),b=o.useMemo(()=>({filters:t,setProviders:s,setModels:n,addProvider:i,addModel:l,removeProvider:m,removeModel:h,clearFilters:p,isFiltered:t.providers.length>0||t.models.length>0,queryString:Jt(t)}),[t,s,n,i,l,m,h,p]);return r.jsx(Ee.Provider,{value:b,children:e})}function Wt(){const e=o.useContext(Ee);if(!e)throw new Error("useFilters() must be used inside a ");return e}const Le="suf:theme",fe="dark";function qt(){if(typeof window>"u")return fe;try{const e=window.localStorage.getItem(Le);if(e==="light"||e==="dark")return e}catch{}return fe}function Kt(e){if(typeof document>"u")return;const t=document.documentElement;e==="dark"?t.classList.add("dark"):t.classList.remove("dark")}function Ht(){const[e,t]=o.useState(()=>qt());o.useEffect(()=>{if(Kt(e),!(typeof window>"u"))try{window.localStorage.setItem(Le,e)}catch{}},[e]);const a=o.useCallback(n=>{t(n)},[]),s=o.useCallback(()=>{t(n=>n==="dark"?"light":"dark")},[]);return{theme:e,toggle:s,setTheme:a}}function Vt({className:e}){const{theme:t,toggle:a}=Ht(),s=t==="dark"?"light":"dark",n=t==="dark"?wt:nt,i=`Switch to ${s} mode`,l="inline-flex items-center justify-center rounded-md p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors",m=e?`${l} ${e}`:l;return r.jsx("button",{type:"button",onClick:a,className:m,"aria-label":i,title:i,children:r.jsx(n,{size:18,stroke:1.75,"aria-hidden":"true"})})}const Yt=1e4,Xt=2e3,Zt=5e3;function er(){const[e,t]=o.useState(!1),a=o.useRef(null),s=o.useRef(null),[n,i]=o.useState(null),{data:l,error:m,isLoading:h}=le({queryKey:["etl-status"],queryFn:Lt,refetchInterval:c=>{var u;const g=c.state.data;return((u=g==null?void 0:g.current_job)==null?void 0:u.status)==="running"?Xt:Yt},staleTime:Zt,retry:(c,g)=>g instanceof G?!1:c<2});if(o.useEffect(()=>{var u;if(!l)return;const c=s.current,g=((u=l.last_job)==null?void 0:u.status)==="failed";if(c&&c!=="live"&&l.health==="live"&&!g){i("ETL caught up");const k=window.setTimeout(()=>i(null),3500);return()=>window.clearTimeout(k)}s.current=l.health},[l]),o.useEffect(()=>{if(!e)return;function c(g){a.current&&!a.current.contains(g.target)&&t(!1)}return document.addEventListener("mousedown",c),()=>document.removeEventListener("mousedown",c)},[e]),m instanceof G)return r.jsxs("div",{className:"hidden md:inline-flex items-center gap-1.5 px-2 py-1 text-[11px] rounded border border-gray-300 dark:border-gray-700 bg-gray-100/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-500",title:"The ETL status route is not available on this build.","aria-label":"ETL pipeline not ready",children:[r.jsx("span",{className:"h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600","aria-hidden":"true"}),r.jsx("span",{className:"hidden lg:inline",children:"ETL pipeline not ready"})]});if(m)return r.jsxs("div",{className:"inline-flex items-center gap-1.5 px-2 py-1 text-[11px] rounded border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400",title:`Failed to fetch ETL status: ${m instanceof Error?m.message:String(m)}`,"aria-label":"ETL status fetch failed",children:[r.jsx("span",{className:"h-1.5 w-1.5 rounded-full bg-red-500","aria-hidden":"true"}),r.jsx("span",{className:"hidden sm:inline",children:"ETL fetch failed"})]});if(h||!l)return r.jsxs("div",{className:"inline-flex items-center gap-1.5 px-2 py-1 text-[11px] rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-500","aria-label":"Loading ETL status",children:[r.jsx("span",{className:"h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600 animate-pulse","aria-hidden":"true"}),r.jsx("span",{className:"hidden sm:inline",children:"ETL…"})]});const p=Tt(l.health),b=Pt(l),d={green:"border-green-300 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300",blue:"border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300",yellow:"border-yellow-300 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",red:"border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"};return r.jsxs("div",{className:"relative",ref:a,children:[r.jsxs("button",{type:"button",onClick:()=>t(c=>!c),className:`inline-flex items-center gap-1.5 px-2 py-1 text-[11px] rounded border transition-colors ${d[p.badge]}`,title:b,"aria-label":b,"aria-expanded":e,children:[r.jsx("span",{className:`h-1.5 w-1.5 rounded-full ${p.dot} ${p.pulse?"animate-pulse":""}`,"aria-hidden":"true"}),r.jsx("span",{className:"hidden sm:inline whitespace-nowrap",children:b})]}),e&&r.jsx(tr,{data:l}),n&&r.jsx("div",{role:"status",className:"absolute right-0 top-full mt-2 z-50 px-3 py-1.5 rounded border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-900/40 text-[11px] text-green-800 dark:text-green-300 shadow-lg whitespace-nowrap",children:n})]})}function tr({data:e}){const{watcher:t,marts:a,events:s,lag_seconds:n,health:i}=e,l=Object.entries(a).sort(([h],[p])=>h.localeCompare(p)),m=Object.entries(s.by_provider).sort(([,h],[,p])=>p-h);return r.jsxs("div",{className:"absolute right-0 top-full mt-2 w-80 z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-xl text-xs",role:"dialog","aria-label":"ETL pipeline status",children:[r.jsxs("div",{className:"px-3 py-2 border-b border-gray-200 dark:border-gray-700",children:[r.jsxs("div",{className:"flex items-center justify-between",children:[r.jsx("span",{className:"font-semibold text-gray-800 dark:text-gray-200",children:"ETL pipeline"}),r.jsx("span",{className:"font-mono uppercase tracking-wide text-[10px] text-gray-500",children:i})]}),r.jsxs("div",{className:"text-[11px] text-gray-500 mt-0.5",children:["Lag ",oe(n)," · ",s.total.toLocaleString()," events total"]})]}),r.jsxs("div",{className:"px-3 py-2 border-b border-gray-200 dark:border-gray-700",children:[r.jsxs("div",{className:"flex items-center justify-between",children:[r.jsx("span",{className:"text-gray-600 dark:text-gray-400",children:"Watcher"}),r.jsx("span",{className:"font-mono text-gray-800 dark:text-gray-200",children:t.enabled?t.running?"running":"enabled · idle":"disabled"})]}),r.jsxs("div",{className:"flex items-center justify-between mt-1",children:[r.jsx("span",{className:"text-gray-600 dark:text-gray-400",children:"Last refresh"}),r.jsx("span",{className:"font-mono text-gray-800 dark:text-gray-200",children:t.last_refresh_ts??"never"})]}),r.jsxs("div",{className:"flex items-center justify-between mt-1",children:[r.jsx("span",{className:"text-gray-600 dark:text-gray-400",children:"Last cycle"}),r.jsx("span",{className:"font-mono text-gray-800 dark:text-gray-200",children:t.events_in_last_cycle!=null?r.jsxs(r.Fragment,{children:[t.events_in_last_cycle.toLocaleString()," event",t.events_in_last_cycle===1?"":"s"]}):"unknown"})]})]}),r.jsxs("div",{className:"px-3 py-2 border-b border-gray-200 dark:border-gray-700",children:[r.jsx("div",{className:"text-gray-600 dark:text-gray-400 mb-1.5",children:"Marts"}),l.length===0?r.jsx("div",{className:"text-gray-500 italic",children:"No marts registered."}):r.jsx("ul",{className:"space-y-1",children:l.map(([h,p])=>{const b=s.max_id-p.watermark;return r.jsxs("li",{className:"flex items-center justify-between",children:[r.jsx("span",{className:"font-mono text-gray-700 dark:text-gray-300",children:h}),r.jsxs("span",{className:"font-mono text-[10px] text-gray-500 tabular-nums",children:["wm ",p.watermark.toLocaleString()," / ",s.max_id.toLocaleString(),b>0?` (-${b.toLocaleString()})`:""," · ",p.row_count.toLocaleString()," rows"]})]},h)})})]}),r.jsxs("div",{className:"px-3 py-2",children:[r.jsx("div",{className:"text-gray-600 dark:text-gray-400 mb-1.5",children:"Events by provider"}),m.length===0?r.jsx("div",{className:"text-gray-500 italic",children:"No events ingested yet."}):r.jsx("ul",{className:"space-y-1",children:m.map(([h,p])=>r.jsxs("li",{className:"flex items-center justify-between",children:[r.jsx("span",{className:"font-mono text-gray-700 dark:text-gray-300",children:h}),r.jsx("span",{className:"font-mono text-[10px] text-gray-500 tabular-nums",children:p.toLocaleString()})]},h))})]})]})}const ie="stackunderflow.project_group_by_provider";function rr(){return typeof localStorage>"u"?!1:localStorage.getItem(ie)==="1"}function ar(e){typeof localStorage>"u"||(e?localStorage.setItem(ie,"1"):localStorage.removeItem(ie))}function sr({onToggleChat:e,chatOpen:t}){const a=De(),s=Se(),{addProvider:n}=Wt(),{data:i}=le({queryKey:["projects",!1],queryFn:()=>$t(!1),staleTime:6e4}),l=(i==null?void 0:i.projects)??[],[m,h]=o.useState(!1),[p,b]=o.useState(""),[d,c]=o.useState(""),[g,u]=o.useState(()=>rr()),[k,$]=o.useState(new Set),[,v]=o.useState(0),N=o.useRef(null),K=()=>{u(f=>{const j=!f;return ar(j),j})},A=f=>{$(j=>{const _=new Set(j);return _.has(f)?_.delete(f):_.add(f),_})},P=s.pathname.match(/^\/project\/(.+?)(?:\/|$)/),C=P?decodeURIComponent(P[1]):null;o.useEffect(()=>{function f(j){N.current&&!N.current.contains(j.target)&&h(!1)}return document.addEventListener("mousedown",f),()=>document.removeEventListener("mousedown",f)},[]),o.useEffect(()=>{const f=()=>v(j=>j+1);return window.addEventListener("namemode-changed",f),()=>window.removeEventListener("namemode-changed",f)},[]);const H=f=>{h(!1),a(`/project/${encodeURIComponent(f)}`)},V=f=>{f.preventDefault(),p.trim()&&C&&a(`/project/${encodeURIComponent(C)}?tab=search&q=${encodeURIComponent(p.trim())}`)},z=_e(),Y=C?J(C,void 0,z):null;return r.jsxs("header",{className:"h-12 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 flex items-center px-4 gap-4 shrink-0",children:[r.jsxs(D,{to:"/",className:"flex items-center gap-2 text-indigo-400 hover:text-indigo-300 shrink-0",children:[r.jsx(bt,{size:22}),r.jsx("span",{className:"font-semibold text-sm hidden sm:inline",children:"StackUnderflow"})]}),r.jsxs("div",{className:"relative",ref:N,children:[r.jsxs("button",{onClick:()=>h(!m),className:"flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 bg-white dark:bg-gray-800 rounded px-2.5 py-1 max-w-[240px]",children:[r.jsx("span",{className:"truncate",children:Y??"Select project"}),r.jsx(ce,{size:14,className:"shrink-0"})]}),m&&r.jsx(nr,{projects:l,currentProject:C,mode:z,projectFilter:d,setProjectFilter:c,groupByProvider:g,toggleGroupMode:K,collapsedGroups:k,toggleGroupCollapsed:A,onSelectProject:f=>{H(f),c("")},onProviderClick:f=>{n(f)}})]}),r.jsxs("nav",{className:"hidden md:flex items-center gap-1 ml-2",children:[r.jsx(D,{to:"/",className:`px-2.5 py-1 rounded text-xs font-medium ${s.pathname==="/"?"bg-white dark:bg-gray-800 text-indigo-400":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100/70 dark:hover:bg-gray-800/50"}`,children:"Overview"}),r.jsx(D,{to:"/live",className:`px-2.5 py-1 rounded text-xs font-medium ${s.pathname==="/live"?"bg-white dark:bg-gray-800 text-indigo-400":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100/70 dark:hover:bg-gray-800/50"}`,children:"Live"}),C&&r.jsx(D,{to:`/project/${encodeURIComponent(C)}`,className:`px-2.5 py-1 rounded text-xs font-medium ${s.pathname.startsWith("/project/")?"bg-white dark:bg-gray-800 text-indigo-400":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100/70 dark:hover:bg-gray-800/50"}`,children:"Dashboard"})]}),r.jsx("div",{className:"flex-1"}),r.jsx("form",{onSubmit:V,className:"hidden sm:flex items-center",children:r.jsxs("div",{className:"relative",children:[r.jsx(mt,{size:14,className:"absolute left-2 top-1/2 -translate-y-1/2 text-gray-500"}),r.jsx("input",{type:"text",value:p,onChange:f=>b(f.target.value),placeholder:"Search messages...",className:"bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded pl-7 pr-3 py-1 text-xs text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500 w-48"})]})}),r.jsx(er,{}),r.jsx(D,{to:"/settings",className:"p-1.5 rounded text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-800",title:"Settings","aria-label":"Open settings",children:r.jsx(pt,{size:18})}),r.jsx(Vt,{}),r.jsx("button",{onClick:e,className:`p-1.5 rounded ${t?"bg-indigo-600 text-white":"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-800"}`,title:"Toggle Ask StackUnderflow",children:r.jsx(ne,{size:18})})]})}function nr({projects:e,currentProject:t,mode:a,projectFilter:s,setProjectFilter:n,groupByProvider:i,toggleGroupMode:l,collapsedGroups:m,toggleGroupCollapsed:h,onSelectProject:p,onProviderClick:b}){const d=s.toLowerCase(),c=d?e.filter(u=>u.dir_name.toLowerCase().includes(d)||(u.display_name||"").toLowerCase().includes(d)||J(u.dir_name,0,a).toLowerCase().includes(d)):e,g=o.useMemo(()=>{const u=new Map;for(const k of c){const $=(k.provider??"").split(",").map(v=>v.trim()).filter(Boolean);if($.length===0){const v="unknown";u.has(v)||u.set(v,[]),u.get(v).push(k)}else for(const v of $){const N=v.toLowerCase();u.has(N)||u.set(N,[]),u.get(N).push(k)}}return Array.from(u.entries()).sort((k,$)=>$[1].length-k[1].length)},[c]);return r.jsxs("div",{className:"absolute top-full left-0 mt-1 w-80 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-xl z-50 flex flex-col max-h-96",children:[r.jsxs("div",{className:"p-2 border-b border-gray-300 dark:border-gray-700 space-y-2",children:[r.jsx("input",{type:"text",value:s,onChange:u=>n(u.target.value),placeholder:"Search projects...",autoFocus:!0,className:"w-full bg-gray-50 dark:bg-gray-900 border border-gray-400 dark:border-gray-600 rounded px-2.5 py-1.5 text-xs text-gray-700 dark:text-gray-300 placeholder-gray-500 focus:outline-none focus:border-indigo-500"}),r.jsxs("div",{className:"flex items-center justify-between text-[11px]",children:[r.jsxs("span",{className:"text-gray-500",children:[c.length," project",c.length===1?"":"s"]}),r.jsx("button",{type:"button",onClick:l,className:`px-1.5 py-0.5 rounded border transition-colors ${i?"border-indigo-500 bg-indigo-500/15 text-indigo-700 dark:text-indigo-300":"border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"}`,title:"Toggle group-by-provider (persists across sessions)",children:i?"∴ Grouped":"· Flat"})]})]}),r.jsx("div",{className:"overflow-y-auto flex-1",children:c.length===0?r.jsx("div",{className:"px-3 py-3 text-xs text-gray-500 text-center",children:"No matches"}):i?g.map(([u,k])=>{const $=m.has(u);return r.jsxs("div",{children:[r.jsxs("div",{className:"flex items-center justify-between px-2 py-1.5 bg-gray-100/70 dark:bg-gray-900/70 sticky top-0 z-10 border-b border-gray-200 dark:border-gray-800",children:[r.jsxs("button",{type:"button",onClick:()=>h(u),className:"flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100",children:[$?r.jsx(de,{size:12}):r.jsx(ce,{size:12}),r.jsx(Dt,{color:Ft(u),size:"sm",children:he(u)}),r.jsxs("span",{className:"text-gray-500 tabular-nums",children:["(",k.length,")"]})]}),r.jsx("button",{type:"button",onClick:v=>{v.stopPropagation(),b(u)},className:"text-[10px] text-indigo-600 dark:text-indigo-400 hover:underline",title:`Filter dashboard to ${he(u)} only`,children:"filter"})]}),!$&&k.map((v,N)=>r.jsxs("button",{onClick:()=>p(v.dir_name),className:`w-full text-left px-3 py-2 text-sm hover:bg-gray-300 dark:hover:bg-gray-700 ${v.dir_name===t?"text-indigo-400 bg-gray-200/50 dark:bg-gray-700/50":"text-gray-700 dark:text-gray-300"}`,children:[r.jsx("div",{className:"truncate",children:J(v.dir_name,N,a)}),r.jsxs("div",{className:"text-xs text-gray-500",children:[v.file_count," files"]})]},`${u}-${v.dir_name}`))]},u)}):c.map((u,k)=>r.jsxs("button",{onClick:()=>p(u.dir_name),className:`w-full text-left px-3 py-2 text-sm hover:bg-gray-300 dark:hover:bg-gray-700 ${u.dir_name===t?"text-indigo-400 bg-gray-200/50 dark:bg-gray-700/50":"text-gray-700 dark:text-gray-300"}`,children:[r.jsx("div",{className:"truncate",children:J(u.dir_name,k,a)}),r.jsxs("div",{className:"text-xs text-gray-500",children:[u.file_count," files"]})]},u.dir_name))})]})}function pe(e){return`/ollama-api${e}`}const or={async chat(e,t,a){var m;const s=await fetch(pe("/chat"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,stream:!0}),signal:a});if(!s.ok){const h=await s.text().catch(()=>"");throw new Error(`Chat failed: ${s.status} ${s.statusText}${h?` - ${h}`:""}`)}const n=(m=s.body)==null?void 0:m.getReader();if(!n)throw new Error("No response body");const i=new TextDecoder;let l="";try{for(;;){const{done:h,value:p}=await n.read();if(h)break;l+=i.decode(p,{stream:!0});const b=l.split(` `);l=b.pop()||"";for(const d of b)if(d.trim())try{const c=JSON.parse(d);t(c)}catch{}}if(l.trim())try{const h=JSON.parse(l);t(h)}catch{}}finally{n.releaseLock()}},async listModels(){try{const e=await fetch(pe("/tags"));return e.ok?(await e.json()).models||[]:[]}catch{return[]}}},ye="/api/meta-agent",ir={async chat(e,t,a){var m;const s=await fetch(`${ye}/chat`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),signal:a});if(!s.ok){const h=await s.text().catch(()=>"");throw new Error(`Meta-agent chat failed: ${s.status} ${s.statusText}${h?` - ${h}`:""}`)}const n=(m=s.body)==null?void 0:m.getReader();if(!n)throw new Error("No response body");const i=new TextDecoder;let l="";try{for(;;){const{done:h,value:p}=await n.read();if(h)break;l+=i.decode(p,{stream:!0});const b=l.split(` `);l=b.pop()||"";for(const d of b)if(d.trim())try{const c=JSON.parse(d);t(c)}catch{}}if(l.trim())try{const h=JSON.parse(l);t(h)}catch{}}finally{n.releaseLock()}},async listTools(){try{const e=await fetch(`${ye}/tools`);return e.ok?await e.json():null}catch{return null}}},lr=["qwen2.5-coder","qwen2.5","llama3.2","llama3.1","firefunction","command-r","mistral-nemo","mistral-large","mixtral","deepseek"];function cr(e){if(!e)return!1;const t=e.toLowerCase();return lr.some(a=>t.includes(a))}function dr(e){const t=e/1073741824;return t>=1?`${t.toFixed(1)} GB`:`${(e/(1024*1024)).toFixed(0)} MB`}function ur({models:e,currentModel:t,onSelectModel:a,onRefresh:s}){return r.jsxs("div",{className:"flex items-center gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-800",children:[r.jsx("label",{className:"text-xs text-gray-500 shrink-0",children:"Model:"}),r.jsxs("select",{value:t,onChange:n=>a(n.target.value),className:"flex-1 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:border-blue-500 min-w-0",children:[e.length===0&&r.jsx("option",{value:"",children:"No models available"}),e.map(n=>r.jsxs("option",{value:n.name,children:[n.name," (",dr(n.size),")"]},n.name))]}),r.jsx("button",{onClick:s,className:"p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors",title:"Refresh model list",children:r.jsx(ut,{size:16})})]})}const be=4096;function ke(e){try{const t=JSON.stringify(e,null,2);return t.length<=be?t:t.slice(0,be)+` -... [truncated for display]`}catch{return String(e)}}function gr(e){return e.result?e.result.ok?`ok · ${e.result.duration_ms}ms`:`error · ${e.result.duration_ms}ms`:"running…"}function mr(e){if(!e.result)return"";const t=e.result.data;if(!t||typeof t!="object")return"";const a=t;return typeof a.count=="number"?`${a.count} matches`:typeof a.file_count=="number"?`${a.file_count} files touched`:typeof a.total_cost_usd=="number"?`$${a.total_cost_usd.toFixed(2)} total`:typeof a.sessions=="number"&&typeof a.cost_usd=="number"?`${a.sessions} sessions · $${a.cost_usd.toFixed(2)}`:typeof a.error=="string"?a.error:""}function hr({invocation:e}){var m;const[t,a]=o.useState(!1),s=gr(e),n=mr(e),i=(m=e.result)==null?void 0:m.ok,l=!e.result;return r.jsxs("div",{"data-testid":"meta-tool-call","data-tool-name":e.name,"data-tool-status":l?"running":i?"ok":"error",className:"my-2 border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs",children:[r.jsxs("button",{type:"button",onClick:()=>a(h=>!h),className:"flex items-center gap-2 w-full px-2.5 py-1.5 text-left hover:bg-gray-100 dark:hover:bg-gray-800/50 rounded-lg",children:[t?r.jsx(ce,{size:12,className:"text-gray-500 shrink-0"}):r.jsx(de,{size:12,className:"text-gray-500 shrink-0"}),r.jsxs("span",{className:"px-1.5 py-0.5 bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded inline-flex items-center gap-1 shrink-0",children:[r.jsx(jt,{size:10}),"tool"]}),r.jsx("span",{className:"font-mono text-gray-800 dark:text-gray-200 truncate",children:e.name}),r.jsx("span",{className:"flex-1"}),n&&r.jsx("span",{className:"text-gray-500 dark:text-gray-400 truncate hidden sm:inline",children:n}),r.jsxs("span",{className:"inline-flex items-center gap-1 text-gray-500 dark:text-gray-400 shrink-0",children:[l&&r.jsx(rt,{size:12,className:"animate-spin"}),!l&&i&&r.jsx(et,{size:12,className:"text-emerald-500"}),!l&&!i&&r.jsx(Ne,{size:12,className:"text-amber-500"}),s]})]}),t&&r.jsxs("div",{className:"px-3 pb-3 pt-1 space-y-2 border-t border-gray-200 dark:border-gray-800",children:[r.jsxs("div",{children:[r.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"args"}),r.jsx("pre",{"data-testid":"meta-tool-args",className:"bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded p-2 overflow-auto text-[11px] text-gray-700 dark:text-gray-300 max-h-48",children:ke(e.args)})]}),e.result&&r.jsxs("div",{children:[r.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"result"}),r.jsx("pre",{"data-testid":"meta-tool-result",className:"bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded p-2 overflow-auto text-[11px] text-gray-700 dark:text-gray-300 max-h-64",children:ke(e.result.data)})]})]})]})}const xr=o.lazy(()=>U(()=>import("./Markdown-Be0CkqQs.js"),__vite__mapDeps([0,1,2,3])));function we(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function fr({messages:e}){const t=o.useRef(null);return o.useEffect(()=>{var a;(a=t.current)==null||a.scrollIntoView({behavior:"smooth"})},[e]),e.length===0?r.jsx("div",{className:"flex-1 flex items-center justify-center p-4",children:r.jsxs("div",{className:"text-center text-gray-500",children:[r.jsx("p",{className:"text-sm",children:"Ask about your sessions, costs, or projects."}),r.jsx("p",{className:"text-xs mt-1 leading-relaxed",children:"The meta agent reads from your local StackUnderflow store — nothing leaves the machine."})]})}):r.jsxs("div",{className:"flex-1 overflow-auto p-3",children:[e.map(a=>r.jsx(pr,{message:a},a.id)),r.jsx("div",{ref:t})]})}function pr({message:e}){return e.role==="user"?r.jsx("div",{className:"flex justify-end mb-3",children:r.jsxs("div",{className:"max-w-[85%] rounded-lg px-3 py-2 bg-blue-600 text-white",children:[r.jsx("div",{className:"text-sm break-words",children:r.jsx("p",{className:"whitespace-pre-wrap",children:e.content})}),r.jsx("div",{className:"text-[10px] mt-1 text-blue-200",children:we(e.timestamp)})]})}):r.jsx("div",{className:"flex justify-start mb-3",children:r.jsxs("div",{className:"max-w-[95%] w-full",children:[e.toolCalls&&e.toolCalls.length>0&&r.jsx("div",{"data-testid":"meta-tool-calls",className:"mb-1",children:e.toolCalls.map(a=>r.jsx(hr,{invocation:a},a.id))}),(e.content||e.error)&&r.jsxs("div",{className:"rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200",children:[r.jsxs("div",{className:"text-sm break-words",children:[e.content&&r.jsx(o.Suspense,{fallback:r.jsx("p",{className:"whitespace-pre-wrap",children:e.content}),children:r.jsx(xr,{content:e.content})}),e.error&&r.jsxs("p",{className:"text-xs text-red-600 dark:text-red-400 mt-1",children:["Error: ",e.error]})]}),r.jsx("div",{className:"text-[10px] mt-1 text-gray-500",children:we(e.timestamp)})]})]})})}function yr({onSend:e,isGenerating:t,onStop:a,disabled:s}){const[n,i]=o.useState(""),l=o.useRef(null),m=o.useCallback(()=>{const b=n.trim();!b||s||(e(b),i(""),l.current&&(l.current.style.height="auto"))},[n,s,e]),h=b=>{b.key==="Enter"&&!b.shiftKey&&(b.preventDefault(),m())},p=b=>{i(b.target.value);const d=b.target;d.style.height="auto",d.style.height=`${Math.min(d.scrollHeight,120)}px`};return r.jsx("div",{className:"border-t border-gray-200 dark:border-gray-800 p-2",children:r.jsxs("div",{className:"flex items-end gap-2",children:[r.jsx("textarea",{ref:l,value:n,onChange:p,onKeyDown:h,placeholder:s?"Select a model to start chatting...":"Ask a question...",disabled:s||t,rows:1,className:"flex-1 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 resize-none focus:outline-none focus:border-blue-500 placeholder-gray-500 dark:placeholder-gray-600 disabled:opacity-50"}),t?r.jsx("button",{onClick:a,className:"p-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors shrink-0",title:"Stop generating",children:r.jsx(it,{size:18})}):r.jsx("button",{onClick:m,disabled:s||!n.trim(),className:"p-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg transition-colors shrink-0",title:"Send message",children:r.jsx(xt,{size:18})})]})})}function br(e){return new Date(e).toLocaleDateString([],{month:"short",day:"numeric"})}function kr({sessions:e,currentSessionId:t,onSwitch:a,onNew:s,onDelete:n}){return r.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800",children:[r.jsxs("div",{className:"flex items-center justify-between px-3 py-1.5",children:[r.jsx("span",{className:"text-[10px] text-gray-500 uppercase tracking-wider",children:"Sessions"}),r.jsx("button",{onClick:s,className:"p-0.5 text-gray-500 hover:text-blue-400 transition-colors",title:"New chat session",children:r.jsx(ct,{size:14})})]}),e.length>0&&r.jsx("div",{className:"max-h-24 overflow-auto px-1 pb-1 space-y-0.5",children:e.map(i=>r.jsxs("div",{className:`flex items-center gap-1 px-2 py-1 rounded text-xs cursor-pointer group ${i.id===t?"bg-gray-300 dark:bg-gray-700 text-gray-800 dark:text-gray-200":"text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300"}`,onClick:()=>a(i.id),children:[r.jsx("span",{className:"flex-1 truncate",children:i.contextLabel}),r.jsx("span",{className:"text-[10px] text-gray-600 dark:text-gray-400 shrink-0",children:br(i.updatedAt)}),r.jsx("button",{onClick:l=>{l.stopPropagation(),n(i.id)},className:"p-0.5 text-gray-600 dark:text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all shrink-0",title:"Delete session",children:r.jsx(Nt,{size:12})})]},i.id))})]})}const Te="stackunderflow_metaAgentSessions";function te(){return`${Date.now()}-${Math.random().toString(36).substring(2,9)}`}function wr(){try{const e=localStorage.getItem(Te);return e?JSON.parse(e).map(a=>({...a,createdAt:new Date(a.createdAt),updatedAt:new Date(a.updatedAt),messages:a.messages.map(s=>({...s,timestamp:new Date(s.timestamp)}))})):[]}catch{return[]}}function vr(e){try{localStorage.setItem(Te,JSON.stringify(e))}catch{}}function jr(e,t,a){if(e){const s=e.question_text.substring(0,40);return s.lengthf.id===i)||null,K=(N==null?void 0:N.messages)||[],A=o.useCallback(async()=>{try{const f=await or.listModels();u(f),$(!0),f.length>0&&!d&&f[0]&&c(f[0].name)}catch{$(!0)}},[d]);o.useEffect(()=>{if(!k){const f=setTimeout(A,1e3);return()=>clearTimeout(f)}},[k,A]),o.useEffect(()=>{vr(s)},[s]);const P=o.useCallback(()=>{const f=jr(e,t,a),j={id:te(),contextLabel:f,createdAt:new Date,updatedAt:new Date,messages:[],projectSlug:a};return n(_=>[j,..._]),l(j.id),j.id},[e,t,a]),C=o.useCallback(async f=>{if(!d){b("No model selected");return}b(null);let j=i;j||(j=P());const _={id:te(),role:"user",content:f,timestamp:new Date},ge=te(),Me={id:ge,role:"assistant",content:"",timestamp:new Date,toolCalls:[]};n(w=>w.map(R=>R.id===j?{...R,messages:[...R.messages,_,Me],updatedAt:new Date}:R));const X=s.find(w=>w.id===j),Ie=[...((X==null?void 0:X.messages)||[]).map(w=>({role:w.role,content:w.content})),{role:"user",content:f}];h(!0);const me=new AbortController;v.current=me;try{await ir.chat({messages:Ie,model:d,tools_enabled:!0,project_slug:a},w=>{n(R=>R.map(M=>{if(M.id!==j)return M;const L=[...M.messages],I=L.findIndex(O=>O.id===ge);if(I<0)return M;const E=L[I];if(!E)return M;if(w.type==="token")L[I]={...E,content:E.content+w.delta};else if(w.type==="tool_call"){const O={id:w.id,name:w.name,args:w.args};L[I]={...E,toolCalls:[...E.toolCalls||[],O]}}else if(w.type==="tool_result"){const O=(E.toolCalls||[]).map(Z=>Z.id===w.id?{...Z,result:{ok:w.ok,data:w.data,duration_ms:w.duration_ms}}:Z);L[I]={...E,toolCalls:O}}else w.type==="error"&&(L[I]={...E,error:w.message});return{...M,messages:L,updatedAt:new Date}}))},me.signal)}catch(w){w instanceof Error&&w.name!=="AbortError"&&b(w.message)}finally{h(!1),v.current=null}},[d,i,s,a,P]),H=o.useCallback(()=>{var f;(f=v.current)==null||f.abort()},[]),V=o.useCallback(f=>{n(j=>j.filter(_=>_.id!==f)),i===f&&l(null)},[i]),z=s.map(f=>({id:f.id,contextLabel:f.contextLabel,updatedAt:f.updatedAt})),Y=cr(d);return r.jsxs("div",{className:"h-full flex flex-col bg-white dark:bg-gray-950",children:[r.jsx(ur,{models:g,currentModel:d,onSelectModel:c,onRefresh:A}),r.jsx(kr,{sessions:z,currentSessionId:i,onSwitch:l,onNew:P,onDelete:V}),!Y&&d&&r.jsxs("div",{"data-testid":"meta-agent-tools-warning",className:"mx-3 mt-2 px-2 py-1.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-800 rounded text-[11px] text-amber-800 dark:text-amber-300 flex items-center gap-1.5",children:[r.jsx(Ne,{size:12,className:"shrink-0"}),r.jsxs("span",{children:["Tool-calling may not work with ",r.jsx("span",{className:"font-mono",children:d})," — pick a tools-capable model (qwen2.5-coder, llama3.2…)"]})]}),p&&r.jsx("div",{className:"mx-3 mt-2 px-3 py-2 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400",children:p}),r.jsx(fr,{messages:K}),r.jsx(yr,{onSend:C,isGenerating:m,onStop:H,disabled:!d})]})}const Pe="stackunderflow_metaAgentSidebar";function re(){try{const e=localStorage.getItem(Pe);if(e==="collapsed"||e==="expanded"||e==="hidden")return e}catch{}return null}function je(e){try{localStorage.setItem(Pe,e)}catch{}}function Sr(e,t){return t<768?"hidden":e||(t>=1280?"expanded":"collapsed")}const B="collapsed",ae="expanded",se="hidden";function Nr({selectedProject:e,forceOverlay:t,onCloseOverlay:a}){const[s,n]=o.useState(()=>Sr(re(),typeof window<"u"?window.innerWidth:1280));o.useEffect(()=>{const m=()=>{const h=window.innerWidth;if(h<768)n(se);else if(s===se)n(re()||(h>=1280?ae:B));else{const p=re();p&&p!==s&&n(p)}};return window.addEventListener("resize",m),()=>window.removeEventListener("resize",m)},[s]);const i=o.useCallback(()=>{n(ae),je(ae)},[]),l=o.useCallback(()=>{n(B),je(B)},[]);return t?r.jsxs("div",{className:"fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-gray-950 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-40 flex flex-col",children:[r.jsxs("div",{className:"flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-800",children:[r.jsx("span",{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:"Ask StackUnderflow"}),r.jsx("button",{onClick:a,className:"p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-800","aria-label":"Close meta agent",children:r.jsx(de,{size:16})})]}),r.jsx("div",{className:"flex-1 overflow-hidden",children:r.jsx(ve,{currentQA:null,currentSessionFile:null,selectedProject:e})})]}):s===se?null:s===B?r.jsx("aside",{"data-testid":"meta-agent-sidebar-rail",className:"w-10 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 flex flex-col items-center py-2 gap-2",children:r.jsx("button",{onClick:i,className:"p-1.5 rounded text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-800",title:"Open Ask StackUnderflow","aria-label":"Expand meta-agent sidebar",children:r.jsx(ne,{size:18})})}):r.jsxs("aside",{"data-testid":"meta-agent-sidebar",className:"w-96 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex flex-col",children:[r.jsxs("header",{className:"flex items-center gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-800",children:[r.jsx(ne,{size:16,className:"text-indigo-500"}),r.jsx("span",{className:"text-sm font-medium text-gray-700 dark:text-gray-200",children:"Ask StackUnderflow"}),e&&r.jsx("span",{className:"px-1.5 py-0.5 text-[10px] bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded font-mono truncate max-w-[120px]",title:`Scoped to ${e}`,children:e}),r.jsx("span",{className:"flex-1"}),r.jsx("button",{onClick:l,className:"p-1 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-800",title:"Collapse sidebar","aria-label":"Collapse meta-agent sidebar",children:r.jsx(Ye,{size:14})})]}),r.jsx("div",{className:"flex-1 overflow-hidden",children:r.jsx(ve,{currentQA:null,currentSessionFile:null,selectedProject:e})})]})}function $r({size:e="md",message:t}){const a={sm:"h-4 w-4",md:"h-8 w-8",lg:"h-12 w-12"};return r.jsxs("div",{className:"flex flex-col items-center justify-center p-4",children:[r.jsx("div",{className:`${a[e]} animate-spin rounded-full border-2 border-gray-300 dark:border-gray-700 border-t-blue-500`}),t&&r.jsx("p",{className:"mt-3 text-sm text-gray-600 dark:text-gray-400",children:t})]})}const Re=o.createContext(null);function _r({warning:e,onDismiss:t}){return r.jsxs("div",{role:"alert",className:"bg-yellow-100 dark:bg-yellow-900/40 border-b border-yellow-300 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100 text-sm px-4 py-2 flex items-start gap-3",children:[r.jsx("span",{"aria-hidden":"true",className:"font-semibold mt-0.5",children:"FX:"}),r.jsx("span",{className:"flex-1 leading-snug",children:e}),r.jsx("button",{type:"button",onClick:t,"aria-label":"Dismiss currency warning",className:"ml-2 text-yellow-900/70 dark:text-yellow-100/70 hover:text-yellow-900 dark:hover:text-yellow-100 font-bold",children:"×"})]})}function Cr({children:e}){var d;const t=Ue(),[a,s]=o.useState(null),n=le({queryKey:["cfg"],queryFn:_t,staleTime:5*6e4}),i=ze({mutationFn:c=>Ct(c),onSuccess:()=>{t.invalidateQueries({queryKey:["cfg"]}),t.invalidateQueries({queryKey:["dashboardData"]}),t.invalidateQueries({queryKey:["projects"]}),t.invalidateQueries({queryKey:["globalStats"]}),t.invalidateQueries({queryKey:["jsonlFiles"]}),s(null)}}),l=o.useCallback(async c=>{await i.mutateAsync(c)},[i]),m=((d=n.data)==null?void 0:d.currency)??null,h={currency:m,isLoading:n.isLoading,setCurrencyCode:l},p=(m==null?void 0:m.warning)??null,b=!!p&&p!==a;return r.jsxs(Re.Provider,{value:h,children:[b&&p&&r.jsx(_r,{warning:p,onDismiss:()=>s(p)}),e]})}function Ia(){const e=o.useContext(Re);return e||{currency:null,isLoading:!1,setCurrencyCode:async()=>{}}}const Er=o.lazy(()=>U(()=>import("./Overview-DRVBiWsj.js"),__vite__mapDeps([4,1,5,6,7,8,9]))),Lr=o.lazy(()=>U(()=>import("./ProjectDashboard-DRHQbkve.js").then(e=>e.P),__vite__mapDeps([10,1,5,8,7,11]))),Tr=o.lazy(()=>U(()=>import("./Live-B7kSsFN1.js"),__vite__mapDeps([12,1,7,5,13,14]))),Pr=o.lazy(()=>U(()=>import("./Settings-Cnnpq-i1.js"),__vite__mapDeps([15,1,11,7,16,17,18])));function Rr(){const t=Se().pathname.match(/^\/project\/(.+?)(?:\/|$)/);return t?decodeURIComponent(t[1]):null}function Mr(){const[e,t]=o.useState(!1),[a,s]=o.useState(()=>typeof window>"u"?!1:window.innerWidth<768),n=Rr();o.useEffect(()=>{const l=()=>s(window.innerWidth<768);return window.addEventListener("resize",l),()=>window.removeEventListener("resize",l)},[]);const i=()=>{if(a)t(l=>!l);else{try{const m=localStorage.getItem("stackunderflow_metaAgentSidebar")==="expanded"?"collapsed":"expanded";localStorage.setItem("stackunderflow_metaAgentSidebar",m)}catch{}window.dispatchEvent(new Event("resize"))}};return r.jsxs("div",{className:"h-screen w-screen bg-white dark:bg-gray-950 flex flex-col",children:[r.jsx(sr,{onToggleChat:i,chatOpen:e}),r.jsxs("div",{className:"flex-1 flex overflow-hidden min-h-0",children:[r.jsx("main",{className:"flex-1 overflow-auto min-w-0",children:r.jsx(o.Suspense,{fallback:r.jsx($r,{size:"md",message:"Loading…"}),children:r.jsxs(Be,{children:[r.jsx(F,{path:"/",element:r.jsx(Er,{})}),r.jsx(F,{path:"/live",element:r.jsx(Tr,{})}),r.jsx(F,{path:"/project/:name",element:r.jsx(Lr,{})}),r.jsx(F,{path:"/settings",element:r.jsx(Pr,{})})]})})}),r.jsx(Nr,{selectedProject:n,forceOverlay:a&&e,onCloseOverlay:()=>t(!1)})]})]})}function Ir(){return r.jsx(Fe,{children:r.jsx(Cr,{children:r.jsx(Qt,{children:r.jsx(Mr,{})})})})}class Ar extends o.Component{constructor(a){super(a);ee(this,"handleReset",()=>{this.setState({hasError:!1,error:null})});this.state={hasError:!1,error:null}}static getDerivedStateFromError(a){return{hasError:!0,error:a}}componentDidCatch(a,s){console.error("ErrorBoundary caught:",a,s)}render(){var a;return this.state.hasError?this.props.fallback?this.props.fallback:r.jsx("div",{className:"flex flex-col items-center justify-center p-6 text-center",children:r.jsxs("div",{className:"rounded-lg border border-red-300 dark:border-red-800 bg-red-100 dark:bg-red-900/30 p-4 max-w-md",children:[r.jsx("h3",{className:"text-red-700 dark:text-red-400 font-semibold text-sm mb-1",children:"Something went wrong"}),r.jsx("p",{className:"text-red-700/80 dark:text-red-300/70 text-xs mb-3",children:((a=this.state.error)==null?void 0:a.message)||"An unexpected error occurred"}),r.jsx("button",{onClick:this.handleReset,className:"px-3 py-1.5 text-xs font-medium rounded bg-red-600 dark:bg-red-800 text-white dark:text-red-200 hover:bg-red-700 dark:hover:bg-red-700 transition-colors",children:"Try again"})]})}):this.props.children}}(function(){try{(window.localStorage.getItem("suf:theme")==="light"?"light":"dark")==="light"?document.documentElement.classList.remove("dark"):document.documentElement.classList.add("dark")}catch{document.documentElement.classList.add("dark")}})();const Or=new Je({defaultOptions:{queries:{staleTime:3e4,retry:1,refetchOnWindowFocus:!1}}});Ge.createRoot(document.getElementById("root")).render(r.jsx(Qe.StrictMode,{children:r.jsx(We,{client:Or,children:r.jsx(Ar,{children:r.jsx(Ir,{})})})}));export{Yr as $,ct as A,Dt as B,Et as C,G as D,Ar as E,Sa as F,et as G,la as H,ut as I,ca as J,Lt as K,$r as L,bt as M,jt as N,ia as O,pa as P,Ra as Q,va as R,rt as S,Ye as T,Gr as U,sa as V,Kr as W,na as X,qr as Y,Xr as Z,Zr as _,Wt as a,oa as a0,Hr as a1,Vr as a2,Qr as a3,Wr as a4,_a as a5,Ca as a6,Na as a7,$a as a8,La as a9,Mt as aa,Pa as ab,Ea as ac,Ta as ad,ma as ae,aa as af,ba as ag,ka as ah,ya as ai,wa as aj,fa as ak,ga as al,ha as am,xa as an,mt as b,$t as c,ta as d,ra as e,J as f,_e as g,S as h,ce as i,de as j,Br as k,Jr as l,he as m,Ft as n,Fr as o,Ne as p,ja as q,ea as r,Ma as s,Ht as t,Ia as u,wt as v,nt as w,da as x,ua as y,Nt as z}; +... [truncated for display]`}catch{return String(e)}}function gr(e){return e.result?e.result.ok?`ok · ${e.result.duration_ms}ms`:`error · ${e.result.duration_ms}ms`:"running…"}function mr(e){if(!e.result)return"";const t=e.result.data;if(!t||typeof t!="object")return"";const a=t;return typeof a.count=="number"?`${a.count} matches`:typeof a.file_count=="number"?`${a.file_count} files touched`:typeof a.total_cost_usd=="number"?`$${a.total_cost_usd.toFixed(2)} total`:typeof a.sessions=="number"&&typeof a.cost_usd=="number"?`${a.sessions} sessions · $${a.cost_usd.toFixed(2)}`:typeof a.error=="string"?a.error:""}function hr({invocation:e}){var m;const[t,a]=o.useState(!1),s=gr(e),n=mr(e),i=(m=e.result)==null?void 0:m.ok,l=!e.result;return r.jsxs("div",{"data-testid":"meta-tool-call","data-tool-name":e.name,"data-tool-status":l?"running":i?"ok":"error",className:"my-2 border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs",children:[r.jsxs("button",{type:"button",onClick:()=>a(h=>!h),className:"flex items-center gap-2 w-full px-2.5 py-1.5 text-left hover:bg-gray-100 dark:hover:bg-gray-800/50 rounded-lg",children:[t?r.jsx(ce,{size:12,className:"text-gray-500 shrink-0"}):r.jsx(de,{size:12,className:"text-gray-500 shrink-0"}),r.jsxs("span",{className:"px-1.5 py-0.5 bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded inline-flex items-center gap-1 shrink-0",children:[r.jsx(jt,{size:10}),"tool"]}),r.jsx("span",{className:"font-mono text-gray-800 dark:text-gray-200 truncate",children:e.name}),r.jsx("span",{className:"flex-1"}),n&&r.jsx("span",{className:"text-gray-500 dark:text-gray-400 truncate hidden sm:inline",children:n}),r.jsxs("span",{className:"inline-flex items-center gap-1 text-gray-500 dark:text-gray-400 shrink-0",children:[l&&r.jsx(rt,{size:12,className:"animate-spin"}),!l&&i&&r.jsx(et,{size:12,className:"text-emerald-500"}),!l&&!i&&r.jsx(Ne,{size:12,className:"text-amber-500"}),s]})]}),t&&r.jsxs("div",{className:"px-3 pb-3 pt-1 space-y-2 border-t border-gray-200 dark:border-gray-800",children:[r.jsxs("div",{children:[r.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"args"}),r.jsx("pre",{"data-testid":"meta-tool-args",className:"bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded p-2 overflow-auto text-[11px] text-gray-700 dark:text-gray-300 max-h-48",children:ke(e.args)})]}),e.result&&r.jsxs("div",{children:[r.jsx("div",{className:"text-[10px] uppercase tracking-wider text-gray-500 mb-1",children:"result"}),r.jsx("pre",{"data-testid":"meta-tool-result",className:"bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded p-2 overflow-auto text-[11px] text-gray-700 dark:text-gray-300 max-h-64",children:ke(e.result.data)})]})]})]})}const xr=o.lazy(()=>U(()=>import("./Markdown-Be0CkqQs.js"),__vite__mapDeps([0,1,2,3])));function we(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function fr({messages:e}){const t=o.useRef(null);return o.useEffect(()=>{var a;(a=t.current)==null||a.scrollIntoView({behavior:"smooth"})},[e]),e.length===0?r.jsx("div",{className:"flex-1 flex items-center justify-center p-4",children:r.jsxs("div",{className:"text-center text-gray-500",children:[r.jsx("p",{className:"text-sm",children:"Ask about your sessions, costs, or projects."}),r.jsx("p",{className:"text-xs mt-1 leading-relaxed",children:"The meta agent reads from your local StackUnderflow store — nothing leaves the machine."})]})}):r.jsxs("div",{className:"flex-1 overflow-auto p-3",children:[e.map(a=>r.jsx(pr,{message:a},a.id)),r.jsx("div",{ref:t})]})}function pr({message:e}){return e.role==="user"?r.jsx("div",{className:"flex justify-end mb-3",children:r.jsxs("div",{className:"max-w-[85%] rounded-lg px-3 py-2 bg-blue-600 text-white",children:[r.jsx("div",{className:"text-sm break-words",children:r.jsx("p",{className:"whitespace-pre-wrap",children:e.content})}),r.jsx("div",{className:"text-[10px] mt-1 text-blue-200",children:we(e.timestamp)})]})}):r.jsx("div",{className:"flex justify-start mb-3",children:r.jsxs("div",{className:"max-w-[95%] w-full",children:[e.toolCalls&&e.toolCalls.length>0&&r.jsx("div",{"data-testid":"meta-tool-calls",className:"mb-1",children:e.toolCalls.map(a=>r.jsx(hr,{invocation:a},a.id))}),(e.content||e.error)&&r.jsxs("div",{className:"rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200",children:[r.jsxs("div",{className:"text-sm break-words",children:[e.content&&r.jsx(o.Suspense,{fallback:r.jsx("p",{className:"whitespace-pre-wrap",children:e.content}),children:r.jsx(xr,{content:e.content})}),e.error&&r.jsxs("p",{className:"text-xs text-red-600 dark:text-red-400 mt-1",children:["Error: ",e.error]})]}),r.jsx("div",{className:"text-[10px] mt-1 text-gray-500",children:we(e.timestamp)})]})]})})}function yr({onSend:e,isGenerating:t,onStop:a,disabled:s}){const[n,i]=o.useState(""),l=o.useRef(null),m=o.useCallback(()=>{const b=n.trim();!b||s||(e(b),i(""),l.current&&(l.current.style.height="auto"))},[n,s,e]),h=b=>{b.key==="Enter"&&!b.shiftKey&&(b.preventDefault(),m())},p=b=>{i(b.target.value);const d=b.target;d.style.height="auto",d.style.height=`${Math.min(d.scrollHeight,120)}px`};return r.jsx("div",{className:"border-t border-gray-200 dark:border-gray-800 p-2",children:r.jsxs("div",{className:"flex items-end gap-2",children:[r.jsx("textarea",{ref:l,value:n,onChange:p,onKeyDown:h,placeholder:s?"Select a model to start chatting...":"Ask a question...",disabled:s||t,rows:1,className:"flex-1 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 resize-none focus:outline-none focus:border-blue-500 placeholder-gray-500 dark:placeholder-gray-600 disabled:opacity-50"}),t?r.jsx("button",{onClick:a,className:"p-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors shrink-0",title:"Stop generating",children:r.jsx(it,{size:18})}):r.jsx("button",{onClick:m,disabled:s||!n.trim(),className:"p-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg transition-colors shrink-0",title:"Send message",children:r.jsx(xt,{size:18})})]})})}function br(e){return new Date(e).toLocaleDateString([],{month:"short",day:"numeric"})}function kr({sessions:e,currentSessionId:t,onSwitch:a,onNew:s,onDelete:n}){return r.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800",children:[r.jsxs("div",{className:"flex items-center justify-between px-3 py-1.5",children:[r.jsx("span",{className:"text-[10px] text-gray-500 uppercase tracking-wider",children:"Sessions"}),r.jsx("button",{onClick:s,className:"p-0.5 text-gray-500 hover:text-blue-400 transition-colors",title:"New chat session",children:r.jsx(ct,{size:14})})]}),e.length>0&&r.jsx("div",{className:"max-h-24 overflow-auto px-1 pb-1 space-y-0.5",children:e.map(i=>r.jsxs("div",{className:`flex items-center gap-1 px-2 py-1 rounded text-xs cursor-pointer group ${i.id===t?"bg-gray-300 dark:bg-gray-700 text-gray-800 dark:text-gray-200":"text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300"}`,onClick:()=>a(i.id),children:[r.jsx("span",{className:"flex-1 truncate",children:i.contextLabel}),r.jsx("span",{className:"text-[10px] text-gray-600 dark:text-gray-400 shrink-0",children:br(i.updatedAt)}),r.jsx("button",{onClick:l=>{l.stopPropagation(),n(i.id)},className:"p-0.5 text-gray-600 dark:text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all shrink-0",title:"Delete session",children:r.jsx(Nt,{size:12})})]},i.id))})]})}const Te="stackunderflow_metaAgentSessions";function te(){return`${Date.now()}-${Math.random().toString(36).substring(2,9)}`}function wr(){try{const e=localStorage.getItem(Te);return e?JSON.parse(e).map(a=>({...a,createdAt:new Date(a.createdAt),updatedAt:new Date(a.updatedAt),messages:a.messages.map(s=>({...s,timestamp:new Date(s.timestamp)}))})):[]}catch{return[]}}function vr(e){try{localStorage.setItem(Te,JSON.stringify(e))}catch{}}function jr(e,t,a){if(e){const s=e.question_text.substring(0,40);return s.lengthf.id===i)||null,K=(N==null?void 0:N.messages)||[],A=o.useCallback(async()=>{try{const f=await or.listModels();u(f),$(!0),f.length>0&&!d&&f[0]&&c(f[0].name)}catch{$(!0)}},[d]);o.useEffect(()=>{if(!k){const f=setTimeout(A,1e3);return()=>clearTimeout(f)}},[k,A]),o.useEffect(()=>{vr(s)},[s]);const P=o.useCallback(()=>{const f=jr(e,t,a),j={id:te(),contextLabel:f,createdAt:new Date,updatedAt:new Date,messages:[],projectSlug:a};return n(_=>[j,..._]),l(j.id),j.id},[e,t,a]),C=o.useCallback(async f=>{if(!d){b("No model selected");return}b(null);let j=i;j||(j=P());const _={id:te(),role:"user",content:f,timestamp:new Date},ge=te(),Me={id:ge,role:"assistant",content:"",timestamp:new Date,toolCalls:[]};n(w=>w.map(R=>R.id===j?{...R,messages:[...R.messages,_,Me],updatedAt:new Date}:R));const X=s.find(w=>w.id===j),Ie=[...((X==null?void 0:X.messages)||[]).map(w=>({role:w.role,content:w.content})),{role:"user",content:f}];h(!0);const me=new AbortController;v.current=me;try{await ir.chat({messages:Ie,model:d,tools_enabled:!0,project_slug:a},w=>{n(R=>R.map(M=>{if(M.id!==j)return M;const L=[...M.messages],I=L.findIndex(O=>O.id===ge);if(I<0)return M;const E=L[I];if(!E)return M;if(w.type==="token")L[I]={...E,content:E.content+w.delta};else if(w.type==="tool_call"){const O={id:w.id,name:w.name,args:w.args};L[I]={...E,toolCalls:[...E.toolCalls||[],O]}}else if(w.type==="tool_result"){const O=(E.toolCalls||[]).map(Z=>Z.id===w.id?{...Z,result:{ok:w.ok,data:w.data,duration_ms:w.duration_ms}}:Z);L[I]={...E,toolCalls:O}}else w.type==="error"&&(L[I]={...E,error:w.message});return{...M,messages:L,updatedAt:new Date}}))},me.signal)}catch(w){w instanceof Error&&w.name!=="AbortError"&&b(w.message)}finally{h(!1),v.current=null}},[d,i,s,a,P]),H=o.useCallback(()=>{var f;(f=v.current)==null||f.abort()},[]),V=o.useCallback(f=>{n(j=>j.filter(_=>_.id!==f)),i===f&&l(null)},[i]),z=s.map(f=>({id:f.id,contextLabel:f.contextLabel,updatedAt:f.updatedAt})),Y=cr(d);return r.jsxs("div",{className:"h-full flex flex-col bg-white dark:bg-gray-950",children:[r.jsx(ur,{models:g,currentModel:d,onSelectModel:c,onRefresh:A}),r.jsx(kr,{sessions:z,currentSessionId:i,onSwitch:l,onNew:P,onDelete:V}),!Y&&d&&r.jsxs("div",{"data-testid":"meta-agent-tools-warning",className:"mx-3 mt-2 px-2 py-1.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-800 rounded text-[11px] text-amber-800 dark:text-amber-300 flex items-center gap-1.5",children:[r.jsx(Ne,{size:12,className:"shrink-0"}),r.jsxs("span",{children:["Tool-calling may not work with ",r.jsx("span",{className:"font-mono",children:d})," — pick a tools-capable model (qwen2.5-coder, llama3.2…)"]})]}),p&&r.jsx("div",{className:"mx-3 mt-2 px-3 py-2 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded text-xs text-red-700 dark:text-red-400",children:p}),r.jsx(fr,{messages:K}),r.jsx(yr,{onSend:C,isGenerating:m,onStop:H,disabled:!d})]})}const Pe="stackunderflow_metaAgentSidebar";function re(){try{const e=localStorage.getItem(Pe);if(e==="collapsed"||e==="expanded"||e==="hidden")return e}catch{}return null}function je(e){try{localStorage.setItem(Pe,e)}catch{}}function Sr(e,t){return t<768?"hidden":e||(t>=1280?"expanded":"collapsed")}const B="collapsed",ae="expanded",se="hidden";function Nr({selectedProject:e,forceOverlay:t,onCloseOverlay:a}){const[s,n]=o.useState(()=>Sr(re(),typeof window<"u"?window.innerWidth:1280));o.useEffect(()=>{const m=()=>{const h=window.innerWidth;if(h<768)n(se);else if(s===se)n(re()||(h>=1280?ae:B));else{const p=re();p&&p!==s&&n(p)}};return window.addEventListener("resize",m),()=>window.removeEventListener("resize",m)},[s]);const i=o.useCallback(()=>{n(ae),je(ae)},[]),l=o.useCallback(()=>{n(B),je(B)},[]);return t?r.jsxs("div",{className:"fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-gray-950 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-40 flex flex-col",children:[r.jsxs("div",{className:"flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-800",children:[r.jsx("span",{className:"text-sm font-medium text-gray-700 dark:text-gray-300",children:"Ask StackUnderflow"}),r.jsx("button",{onClick:a,className:"p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-800","aria-label":"Close meta agent",children:r.jsx(de,{size:16})})]}),r.jsx("div",{className:"flex-1 overflow-hidden",children:r.jsx(ve,{currentQA:null,currentSessionFile:null,selectedProject:e})})]}):s===se?null:s===B?r.jsx("aside",{"data-testid":"meta-agent-sidebar-rail",className:"w-10 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 flex flex-col items-center py-2 gap-2",children:r.jsx("button",{onClick:i,className:"p-1.5 rounded text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-800",title:"Open Ask StackUnderflow","aria-label":"Expand meta-agent sidebar",children:r.jsx(ne,{size:18})})}):r.jsxs("aside",{"data-testid":"meta-agent-sidebar",className:"w-96 shrink-0 border-l border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex flex-col",children:[r.jsxs("header",{className:"flex items-center gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-800",children:[r.jsx(ne,{size:16,className:"text-indigo-500"}),r.jsx("span",{className:"text-sm font-medium text-gray-700 dark:text-gray-200",children:"Ask StackUnderflow"}),e&&r.jsx("span",{className:"px-1.5 py-0.5 text-[10px] bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded font-mono truncate max-w-[120px]",title:`Scoped to ${e}`,children:e}),r.jsx("span",{className:"flex-1"}),r.jsx("button",{onClick:l,className:"p-1 rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-800",title:"Collapse sidebar","aria-label":"Collapse meta-agent sidebar",children:r.jsx(Ye,{size:14})})]}),r.jsx("div",{className:"flex-1 overflow-hidden",children:r.jsx(ve,{currentQA:null,currentSessionFile:null,selectedProject:e})})]})}function $r({size:e="md",message:t}){const a={sm:"h-4 w-4",md:"h-8 w-8",lg:"h-12 w-12"};return r.jsxs("div",{className:"flex flex-col items-center justify-center p-4",children:[r.jsx("div",{className:`${a[e]} animate-spin rounded-full border-2 border-gray-300 dark:border-gray-700 border-t-blue-500`}),t&&r.jsx("p",{className:"mt-3 text-sm text-gray-600 dark:text-gray-400",children:t})]})}const Re=o.createContext(null);function _r({warning:e,onDismiss:t}){return r.jsxs("div",{role:"alert",className:"bg-yellow-100 dark:bg-yellow-900/40 border-b border-yellow-300 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100 text-sm px-4 py-2 flex items-start gap-3",children:[r.jsx("span",{"aria-hidden":"true",className:"font-semibold mt-0.5",children:"FX:"}),r.jsx("span",{className:"flex-1 leading-snug",children:e}),r.jsx("button",{type:"button",onClick:t,"aria-label":"Dismiss currency warning",className:"ml-2 text-yellow-900/70 dark:text-yellow-100/70 hover:text-yellow-900 dark:hover:text-yellow-100 font-bold",children:"×"})]})}function Cr({children:e}){var d;const t=Ue(),[a,s]=o.useState(null),n=le({queryKey:["cfg"],queryFn:_t,staleTime:5*6e4}),i=ze({mutationFn:c=>Ct(c),onSuccess:()=>{t.invalidateQueries({queryKey:["cfg"]}),t.invalidateQueries({queryKey:["dashboardData"]}),t.invalidateQueries({queryKey:["projects"]}),t.invalidateQueries({queryKey:["globalStats"]}),t.invalidateQueries({queryKey:["jsonlFiles"]}),s(null)}}),l=o.useCallback(async c=>{await i.mutateAsync(c)},[i]),m=((d=n.data)==null?void 0:d.currency)??null,h={currency:m,isLoading:n.isLoading,setCurrencyCode:l},p=(m==null?void 0:m.warning)??null,b=!!p&&p!==a;return r.jsxs(Re.Provider,{value:h,children:[b&&p&&r.jsx(_r,{warning:p,onDismiss:()=>s(p)}),e]})}function Ia(){const e=o.useContext(Re);return e||{currency:null,isLoading:!1,setCurrencyCode:async()=>{}}}const Er=o.lazy(()=>U(()=>import("./Overview-BqyHxuZ_.js"),__vite__mapDeps([4,1,5,6,7,8,9]))),Lr=o.lazy(()=>U(()=>import("./ProjectDashboard-DAsn11K-.js").then(e=>e.P),__vite__mapDeps([10,1,5,8,7,11]))),Tr=o.lazy(()=>U(()=>import("./Live-BX6jEGZn.js"),__vite__mapDeps([12,1,7,5,13,14,15]))),Pr=o.lazy(()=>U(()=>import("./Settings-BSujxVUW.js"),__vite__mapDeps([16,1,11,7,17,18,19])));function Rr(){const t=Se().pathname.match(/^\/project\/(.+?)(?:\/|$)/);return t?decodeURIComponent(t[1]):null}function Mr(){const[e,t]=o.useState(!1),[a,s]=o.useState(()=>typeof window>"u"?!1:window.innerWidth<768),n=Rr();o.useEffect(()=>{const l=()=>s(window.innerWidth<768);return window.addEventListener("resize",l),()=>window.removeEventListener("resize",l)},[]);const i=()=>{if(a)t(l=>!l);else{try{const m=localStorage.getItem("stackunderflow_metaAgentSidebar")==="expanded"?"collapsed":"expanded";localStorage.setItem("stackunderflow_metaAgentSidebar",m)}catch{}window.dispatchEvent(new Event("resize"))}};return r.jsxs("div",{className:"h-screen w-screen bg-white dark:bg-gray-950 flex flex-col",children:[r.jsx(sr,{onToggleChat:i,chatOpen:e}),r.jsxs("div",{className:"flex-1 flex overflow-hidden min-h-0",children:[r.jsx("main",{className:"flex-1 overflow-auto min-w-0",children:r.jsx(o.Suspense,{fallback:r.jsx($r,{size:"md",message:"Loading…"}),children:r.jsxs(Be,{children:[r.jsx(F,{path:"/",element:r.jsx(Er,{})}),r.jsx(F,{path:"/live",element:r.jsx(Tr,{})}),r.jsx(F,{path:"/project/:name",element:r.jsx(Lr,{})}),r.jsx(F,{path:"/settings",element:r.jsx(Pr,{})})]})})}),r.jsx(Nr,{selectedProject:n,forceOverlay:a&&e,onCloseOverlay:()=>t(!1)})]})]})}function Ir(){return r.jsx(Fe,{children:r.jsx(Cr,{children:r.jsx(Qt,{children:r.jsx(Mr,{})})})})}class Ar extends o.Component{constructor(a){super(a);ee(this,"handleReset",()=>{this.setState({hasError:!1,error:null})});this.state={hasError:!1,error:null}}static getDerivedStateFromError(a){return{hasError:!0,error:a}}componentDidCatch(a,s){console.error("ErrorBoundary caught:",a,s)}render(){var a;return this.state.hasError?this.props.fallback?this.props.fallback:r.jsx("div",{className:"flex flex-col items-center justify-center p-6 text-center",children:r.jsxs("div",{className:"rounded-lg border border-red-300 dark:border-red-800 bg-red-100 dark:bg-red-900/30 p-4 max-w-md",children:[r.jsx("h3",{className:"text-red-700 dark:text-red-400 font-semibold text-sm mb-1",children:"Something went wrong"}),r.jsx("p",{className:"text-red-700/80 dark:text-red-300/70 text-xs mb-3",children:((a=this.state.error)==null?void 0:a.message)||"An unexpected error occurred"}),r.jsx("button",{onClick:this.handleReset,className:"px-3 py-1.5 text-xs font-medium rounded bg-red-600 dark:bg-red-800 text-white dark:text-red-200 hover:bg-red-700 dark:hover:bg-red-700 transition-colors",children:"Try again"})]})}):this.props.children}}(function(){try{(window.localStorage.getItem("suf:theme")==="light"?"light":"dark")==="light"?document.documentElement.classList.remove("dark"):document.documentElement.classList.add("dark")}catch{document.documentElement.classList.add("dark")}})();const Or=new Je({defaultOptions:{queries:{staleTime:3e4,retry:1,refetchOnWindowFocus:!1}}});Ge.createRoot(document.getElementById("root")).render(r.jsx(Qe.StrictMode,{children:r.jsx(We,{client:Or,children:r.jsx(Ar,{children:r.jsx(Ir,{})})})}));export{Yr as $,ct as A,Dt as B,Et as C,G as D,Ar as E,Sa as F,et as G,la as H,ut as I,ca as J,Lt as K,$r as L,bt as M,jt as N,ia as O,pa as P,Ra as Q,va as R,rt as S,Ye as T,Gr as U,sa as V,Kr as W,na as X,qr as Y,Xr as Z,Zr as _,Wt as a,oa as a0,Hr as a1,Vr as a2,Qr as a3,Wr as a4,_a as a5,Ca as a6,Na as a7,$a as a8,La as a9,Mt as aa,Pa as ab,Ea as ac,Ta as ad,ma as ae,aa as af,ba as ag,ka as ah,ya as ai,wa as aj,fa as ak,ga as al,ha as am,xa as an,mt as b,$t as c,ta as d,ra as e,J as f,_e as g,S as h,ce as i,de as j,Br as k,Jr as l,he as m,Ft as n,Fr as o,Ne as p,ja as q,ea as r,Ma as s,Ht as t,Ia as u,wt as v,nt as w,da as x,ua as y,Nt as z}; diff --git a/stackunderflow/static/react/index.html b/stackunderflow/static/react/index.html index 010e9dc..e247932 100644 --- a/stackunderflow/static/react/index.html +++ b/stackunderflow/static/react/index.html @@ -5,7 +5,7 @@ StackUnderflow - + diff --git a/tests/stackunderflow/hooks/test_proactive.py b/tests/stackunderflow/hooks/test_proactive.py index 07a1f91..aff3cb8 100644 --- a/tests/stackunderflow/hooks/test_proactive.py +++ b/tests/stackunderflow/hooks/test_proactive.py @@ -29,9 +29,10 @@ import stackunderflow.deps as deps from stackunderflow import settings as settings_mod from stackunderflow.hooks import proactive, recall -from stackunderflow.reports.patterns import _normalise_command +from stackunderflow.reports.patterns import _normalise_command, _normalise_signature RECALL_ID = "stackunderflow-pretool-recall" +NUDGE_ID = "stackunderflow-posttool-nudge" # A pinned clock for the deterministic gate tests. The ingest-integration test # seeds relative to the real clock (it drives the real ``mine_patterns`` window). @@ -498,3 +499,449 @@ def test_refresh_is_a_noop_when_disabled(self, isolate, monkeypatch): finally: conn.close() assert not proactive._signal_path().exists() + + def test_refresh_caches_error_signatures(self, isolate, monkeypatch): + # Phase 2: the cache now also carries the mined error_signatures map. + _enable(monkeypatch) + slug = proactive._slug_from_cwd("/repo/demo") + conn = _seed_store_with_error_signature(isolate, slug=slug) + try: + proactive.refresh_signal_cache(conn, [slug]) + finally: + conn.close() + cache = json.loads(proactive._signal_path().read_text()) + sigs = cache["projects"][slug]["error_signatures"] + assert sigs, "error_signatures family should be populated" + entry = next(iter(sigs.values())) + assert entry["session_count"] == 2 + assert entry["resolution_hints"], "the post-error tool call should yield a hint" + + +# ── (9) error-signature nudge — the signal (Phase 2, PostToolUse/Bash) ──────── + +# A raw error and the signature key ``_normalise_signature`` derives from it. +# The hook re-uses that exact function, so a payload carrying ``RAW_IMPORT_ERR`` +# on stderr looks up the ``SIG_KEY`` entry. +RAW_IMPORT_ERR = "ModuleNotFoundError: No module named 'foo'" +SIG_KEY = _normalise_signature(RAW_IMPORT_ERR) + + +def _enable_errsig(monkeypatch): + """Enable proactive AND allow the error-signature type (not on by default).""" + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_ENABLED", "1") + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_TYPES", "command-cluster,file-risk,error-signature") + + +def _errsig(signature=SIG_KEY, sc=4, count=9, hints=..., cat="Import Error", example=None): + if hints is ...: + hints = [{"action": "Bash pip install -e .", "count": 3}] + return { + "signature": signature, + "category": cat, + "session_count": sc, + "count": count, + "resolution_hints": hints, + "last_ts": (NOW - timedelta(days=2)).isoformat(), + "example": example if example is not None else signature, + } + + +def _write_sig_cache(sigs, *, cwd="/repo/demo"): + slug = proactive._slug_from_cwd(cwd) + cache = { + "version": 1, + "generated_at": NOW.isoformat(), + "projects": { + slug: { + "generated_at": NOW.isoformat(), + "command_clusters": {}, + "file_risk": {}, + "error_signatures": sigs, + } + }, + } + proactive._write_json(proactive._signal_path(), cache) + return slug + + +def _post_bash(stderr=RAW_IMPORT_ERR, *, cwd="/repo/demo", session="s1", tool_name="Bash", response=...): + if response is ...: + response = {"stderr": stderr, "exit_code": 1} + return { + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": {"command": "python -c 'import foo'"}, + "tool_response": response, + "cwd": cwd, + "session_id": session, + } + + +class TestErrorSignatureSignal: + def test_fires_on_recurring_signature_with_hints(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig(sc=4)}) + out = proactive.error_signature_block(_post_bash(), now=NOW) + assert out.startswith("[StackUnderflow memory]") + assert "recurred in 4 sessions" in out + assert RAW_IMPORT_ERR in out + assert "`Bash pip install -e .`" in out # the top resolution hint + + def test_silent_on_one_off_single_session(self, isolate, monkeypatch): + # session_count 1 is below the recurrence floor → silent. + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig(sc=1)}) + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_silent_when_no_resolution_hints(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig(sc=5, hints=[])}) + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_silent_on_clean_result(self, isolate, monkeypatch): + # A successful call (exit 0, no stderr) yields no error body → silent. + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + clean = _post_bash(response={"stdout": "ok", "stderr": "", "exit_code": 0}) + assert proactive.error_signature_block(clean, now=NOW) == "" + + def test_silent_on_unknown_signature(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + other = _post_bash(stderr="PermissionError: [Errno 13] Permission denied") + assert proactive.error_signature_block(other, now=NOW) == "" + + def test_silent_on_non_bash(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.error_signature_block(_post_bash(tool_name="Edit"), now=NOW) == "" + + def test_silent_on_missing_cache(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) # no cache file at all + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_silent_when_type_not_in_allowlist(self, isolate, monkeypatch): + # Enabled, but error-signature omitted from the type allowlist. + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_ENABLED", "1") + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_TYPES", "command-cluster,file-risk") + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_disabled_is_silent(self, isolate, monkeypatch): + # proactive_enabled defaults false → passthrough → no error nudge. + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_uses_normalise_signature_verbatim(self): + # Parity by reuse: two paths/numbers normalise to one key. + a = _normalise_signature("File /a/b/foo.py:212 not found") + b = _normalise_signature("File /x/foo.py:7 not found") + assert a == b + + def test_garbage_payload_is_silent(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + for bad in (None, "x", 42, {}, {"tool_name": "Bash", "tool_response": "nope"}): + assert proactive.error_signature_block(bad, now=NOW) == "" + + +# ── (10) error-signature nudge — governance (rides the Phase-1 layer) ───────── + + +class TestErrorSignatureGovernance: + def test_dedupe_same_session(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + first = proactive.error_signature_block(_post_bash(session="s1"), now=NOW) + second = proactive.error_signature_block(_post_bash(session="s1"), now=NOW) + assert first.startswith("[StackUnderflow memory]") + assert second == "" # deduped (and cooling down) + + def test_cap_across_types(self, isolate, monkeypatch): + # A global cap of 1 with two distinct eligible signatures → one fires. + _enable_errsig(monkeypatch) + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_MAX_PER_SESSION", "1") + other_raw = "ImportError: cannot import name bar" + other_key = _normalise_signature(other_raw) + _write_sig_cache({SIG_KEY: _errsig(), other_key: _errsig(signature=other_key)}) + a = proactive.error_signature_block(_post_bash(stderr=RAW_IMPORT_ERR, session="s1"), now=NOW) + b = proactive.error_signature_block(_post_bash(stderr=other_raw, session="s1"), now=NOW) + assert bool(a) != bool(b) # exactly one fired + + def test_cooldown_across_sessions(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_COOLDOWN_HOURS", "24") + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.error_signature_block(_post_bash(session="s1"), now=NOW) != "" + # Same signature (→ same fingerprint), different session, still cooling down. + assert proactive.error_signature_block(_post_bash(session="s2"), now=NOW + timedelta(hours=1)) == "" + # After the window it re-arms. + assert proactive.error_signature_block(_post_bash(session="s3"), now=NOW + timedelta(hours=25)) != "" + + def test_adaptive_quieting_by_type(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + for _ in range(3): # default proactive_dismiss_suppress_after = 3 + proactive.record_dismissal(proactive.TYPE_ERROR_SIGNATURE, now=NOW) + assert proactive.error_signature_block(_post_bash(), now=NOW) == "" + + def test_should_surface_gate_for_error_type(self): + pol = proactive.Policy( + enabled=True, kill_switch=False, + types=frozenset({proactive.TYPE_ERROR_SIGNATURE}), + max_per_session=3, cooldown_hours=24.0, dismiss_suppress_after=3, + ) + sig = proactive.make_signal(proactive.TYPE_ERROR_SIGNATURE, SIG_KEY, "s1", (4, 9), eligible=True) + assert proactive.should_surface(sig, {}, policy=pol, now=NOW) is True + ineligible = proactive.make_signal(proactive.TYPE_ERROR_SIGNATURE, SIG_KEY, "s1", (4, 9), eligible=False) + assert proactive.should_surface(ineligible, {}, policy=pol, now=NOW) is False + + +# ── (11) build_posttool_nudge — the PostToolUse envelope ───────────────────── + + +class TestBuildPosttoolNudge: + def test_emits_additional_context_envelope(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + out = proactive.build_posttool_nudge(NUDGE_ID, _post_bash(session="fresh")) + obj = json.loads(out) + assert set(obj) == {"hookSpecificOutput"} + assert set(obj["hookSpecificOutput"]) == {"hookEventName", "additionalContext"} + assert obj["hookSpecificOutput"]["hookEventName"] == "PostToolUse" + assert RAW_IMPORT_ERR in obj["hookSpecificOutput"]["additionalContext"] + + def test_never_emits_a_deny_or_decision(self, isolate, monkeypatch): + # A PostToolUse hook must never block the tool — only advisory context. + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + out = proactive.build_posttool_nudge(NUDGE_ID, _post_bash(session="fresh")) + for banned in ('"decision"', "permissionDecision", "deny", '"block"', '"ask"', "continue"): + assert banned not in out + + def test_default_off_is_silent(self, isolate, monkeypatch): + # proactive_enabled defaults false → passthrough → no envelope. + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.build_posttool_nudge(NUDGE_ID, _post_bash()) == "" + + def test_type_excluded_is_silent(self, isolate, monkeypatch): + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_ENABLED", "1") + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_TYPES", "command-cluster,file-risk") + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.build_posttool_nudge(NUDGE_ID, _post_bash()) == "" + + def test_killswitch_is_silent(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_DISABLED", "1") + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.mode() == "off" + assert proactive.build_posttool_nudge(NUDGE_ID, _post_bash()) == "" + + def test_wrong_hook_id_is_silent(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + assert proactive.build_posttool_nudge("stackunderflow-pretool-recall", _post_bash()) == "" + + def test_garbage_payload_is_silent(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + for bad in (None, "x", 42, {}, []): + assert proactive.build_posttool_nudge(NUDGE_ID, bad) == "" + + +# ── (12) error-body extraction from a PostToolUse tool_response ─────────────── + + +class TestErrorBodyExtraction: + def test_prefers_stderr(self): + assert proactive._error_body_from_response( + {"tool_response": {"stdout": "noise", "stderr": "boom", "exit_code": 1}} + ) == "boom" + + def test_error_field(self): + out = proactive._error_body_from_response({"tool_response": {"error": "permission denied"}}) + assert out == "permission denied" + + def test_is_error_content_string(self): + assert proactive._error_body_from_response( + {"tool_response": {"is_error": True, "content": "traceback here"}} + ) == "traceback here" + + def test_success_false_content(self): + assert proactive._error_body_from_response( + {"tool_response": {"success": False, "content": "failed thing"}} + ) == "failed thing" + + def test_list_tool_result_blocks(self): + body = proactive._error_body_from_response( + {"tool_response": [{"type": "tool_result", "is_error": True, "content": "list err"}]} + ) + assert "list err" in body + + def test_clean_response_yields_empty(self): + # stdout only, exit 0, no error flag → no error body. + assert proactive._error_body_from_response({"tool_response": {"stdout": "all good", "exit_code": 0}}) == "" + + def test_bare_string_response(self): + assert proactive._error_body_from_response({"tool_response": "some error text"}) == "some error text" + + +# ── (13) error-signature cache precompute (ingest side, integration) ───────── + + +def _seed_store_with_error_signature(tmp_path, *, slug): + """A store where the same error recurs in 2 sessions, each followed by a + tool call (so ``mine_patterns`` derives a resolution hint).""" + from stackunderflow.store import db, schema + + conn = db.connect(tmp_path / "store.db") + schema.apply(conn) + pid = int( + conn.execute( + "INSERT INTO projects (provider, slug, display_name, first_seen, last_modified) " + "VALUES ('claude', ?, ?, 0, 0)", + (slug, slug), + ).lastrowid + ) + recent = datetime.now(UTC) - timedelta(days=2) + err_body = "ImportError: cannot import name widget from pkg" + for i in (1, 2): + sid = f"err-s{i}" + sfk = int( + conn.execute( + "INSERT INTO sessions (project_id, session_id, first_ts, last_ts, message_count) " + "VALUES (?, ?, NULL, NULL, 0)", + (pid, sid), + ).lastrowid + ) + t0 = recent + timedelta(minutes=i) + tu = f"tu-{i}" + # assistant: the failing Bash call + conn.execute( + "INSERT INTO messages (session_fk, seq, timestamp, role, model, input_tokens, " + " output_tokens, cache_create_tokens, cache_read_tokens, content_text, tools_json, " + " raw_json, is_sidechain, uuid, parent_uuid, speed) " + "VALUES (?, 1, ?, 'assistant', '', 0,0,0,0, '', ?, ?, 0, '', NULL, 'standard')", + ( + sfk, t0.isoformat(), + json.dumps([{"id": tu, "name": "Bash", "input": {"command": "python w.py"}}]), + json.dumps({ + "type": "assistant", "timestamp": t0.isoformat(), + "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": tu, "name": "Bash", "input": {"command": "python w.py"}} + ]}, + }), + ), + ) + # user: the errored tool_result carrying the recurring signature + t1 = t0 + timedelta(seconds=30) + conn.execute( + "INSERT INTO messages (session_fk, seq, timestamp, role, model, input_tokens, " + " output_tokens, cache_create_tokens, cache_read_tokens, content_text, tools_json, " + " raw_json, is_sidechain, uuid, parent_uuid, speed) " + "VALUES (?, 2, ?, 'user', '', 0,0,0,0, ?, '[]', ?, 0, '', NULL, 'standard')", + ( + sfk, t1.isoformat(), err_body, + json.dumps({ + "type": "user", "timestamp": t1.isoformat(), + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": tu, "is_error": True, "content": err_body} + ]}, + }), + ), + ) + # message_tool_mart: a tool call AFTER the error → the resolution hint. + t2 = t1 + timedelta(seconds=30) + conn.execute( + "INSERT INTO message_tool_mart " + "(message_id, project_id, session_id, ts, day, tool_name, file_path, byte_count, call_index) " + "VALUES (?, ?, ?, ?, ?, 'Edit', '/repo/fix.py', NULL, 0)", + (5000 + i, pid, sid, t2.isoformat(), t2.isoformat()[:10]), + ) + conn.commit() + return conn + + +class TestErrorSignatureCachePrecomputeFires: + def test_refresh_then_hook_fires_on_recurring_error(self, isolate, monkeypatch): + _enable_errsig(monkeypatch) + slug = proactive._slug_from_cwd("/repo/demo") + conn = _seed_store_with_error_signature(isolate, slug=slug) + try: + proactive.refresh_signal_cache(conn, [slug]) + finally: + conn.close() + + cache = json.loads(proactive._signal_path().read_text()) + sigs = cache["projects"][slug]["error_signatures"] + key = _normalise_signature("ImportError: cannot import name widget from pkg") + assert key in sigs + assert sigs[key]["session_count"] == 2 + assert sigs[key]["resolution_hints"] + + # The hook now fires for a fresh error with that signature (real clock). + out = proactive.error_signature_block( + _post_bash(stderr="ImportError: cannot import name widget from pkg", session="live") + ) + assert out.startswith("[StackUnderflow memory]") + assert "recurred in 2 sessions" in out + + +# ── (14) the new nudge hook — registration + install wiring ────────────────── + + +class TestNudgeHookRegistration: + def test_id_and_event_registered(self): + from stackunderflow.hooks import templates + + assert NUDGE_ID in templates.ALL_HOOK_IDS + assert templates.NUDGE_HOOK_IDS == (NUDGE_ID,) + assert templates.HOOK_ID_EVENTS[NUDGE_ID] == "PostToolUse" + assert NUDGE_ID not in templates.HOOK_IDS # not a capture hook + + def test_parse_and_canonical_round_trip(self): + from stackunderflow.hooks import templates + + cmd = templates.canonical_command(NUDGE_ID) + assert cmd == f"stackunderflow hooks run {NUDGE_ID}" + assert templates.parse_hook_command(cmd) == (NUDGE_ID, False) + + def test_canonical_block_includes_nudge_only_with_inject(self): + from stackunderflow.hooks import templates + + plain = templates.canonical_hooks_block() + plain_post = [e["command"] for g in plain["PostToolUse"] for e in g["hooks"]] + assert f"stackunderflow hooks run {NUDGE_ID}" not in plain_post + + with_inject = templates.canonical_hooks_block(inject=True) + post = [e["command"] for g in with_inject["PostToolUse"] for e in g["hooks"]] + assert f"stackunderflow hooks run {NUDGE_ID}" in post + # the capture PostToolUse hook still coexists in the same event + assert "stackunderflow hooks run stackunderflow-post-tool-use" in post + + +# ── (15) handler dispatch — handlers.run → build_posttool_nudge ─────────────── + + +class TestNudgeDispatch: + def test_run_dispatches_and_prints_envelope(self, isolate, monkeypatch, capsys): + from stackunderflow.hooks.handlers import run as hook_run + + _enable_errsig(monkeypatch) + _write_sig_cache({SIG_KEY: _errsig()}) + rc = hook_run(NUDGE_ID, _post_bash(session="dispatch")) + assert rc == 0 + out = capsys.readouterr().out + assert '"hookSpecificOutput"' in out + assert "PostToolUse" in out + assert out.endswith("\n") + for banned in ("permissionDecision", '"deny"', '"decision"'): + assert banned not in out + + def test_run_is_silent_when_disabled(self, isolate, monkeypatch, capsys): + # default-off (isolate clears the env + points settings at a clean file). + from stackunderflow.hooks.handlers import run as hook_run + + _write_sig_cache({SIG_KEY: _errsig()}) + rc = hook_run(NUDGE_ID, _post_bash(session="dispatch-off")) + assert rc == 0 + assert capsys.readouterr().out == "" diff --git a/tests/stackunderflow/routes/test_patterns_route.py b/tests/stackunderflow/routes/test_patterns_route.py index d7a9a16..b24a77d 100644 --- a/tests/stackunderflow/routes/test_patterns_route.py +++ b/tests/stackunderflow/routes/test_patterns_route.py @@ -202,3 +202,91 @@ def test_patterns_route_registered_on_app(): from tests.conftest import app_route_paths assert "/api/patterns" in app_route_paths(app) + + +# ── POST /api/patterns/dismiss — the Tier-2 → governance write (Phase 2) ────── + + +@pytest.mark.asyncio +async def test_dismiss_writes_the_exact_tier1_fingerprint(tmp_path, monkeypatch): + # A dashboard dismiss must land on the SAME governance key the in-session + # hook governance reads — the round-trip guarantee. + monkeypatch.setattr("stackunderflow.deps.store_path", tmp_path / "store.db") + from stackunderflow.hooks import proactive + from stackunderflow.routes.patterns import DismissRequest, dismiss_pattern + + target = "ModuleNotFoundError: No module named " + # What Tier-1 (error_signature_block) builds for this recurring signature: + tier1 = proactive.make_signal("error-signature", target, "any-session", (4, 9), eligible=True) + + body = await dismiss_pattern( + DismissRequest(type="error-signature", scope="fingerprint", target_key=target, counts=[4, 9]) + ) + assert body["ok"] is True + assert body["scope"] == "fingerprint" + assert body["dismissed"] == tier1.fingerprint # byte-identical to Tier-1's + + state = json.loads(proactive._state_path().read_text()) + assert state["feedback"][tier1.fingerprint]["dismissed"] == 1 + + +@pytest.mark.asyncio +async def test_dismiss_fingerprint_scope_quiets_tier1(tmp_path, monkeypatch): + # After suppress_after (default 3) dismissals of a fingerprint, the exact + # Tier-1 signal is suppressed by should_surface. + monkeypatch.setattr("stackunderflow.deps.store_path", tmp_path / "store.db") + monkeypatch.setattr("stackunderflow.settings._CFG_FILE", tmp_path / "config.json") + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_ENABLED", "1") + monkeypatch.setenv("STACKUNDERFLOW_PROACTIVE_TYPES", "error-signature") + from stackunderflow.hooks import proactive + from stackunderflow.routes.patterns import DismissRequest, dismiss_pattern + + target, counts = "some recurring signature", [3, 7] + for _ in range(3): + await dismiss_pattern( + DismissRequest(type="error-signature", scope="fingerprint", target_key=target, counts=counts) + ) + sig = proactive.make_signal("error-signature", target, "s1", (3, 7), eligible=True) + state = json.loads(proactive._state_path().read_text()) + assert proactive.should_surface(sig, state) is False # adaptive quieting kicked in + + +@pytest.mark.asyncio +async def test_dismiss_type_scope(tmp_path, monkeypatch): + monkeypatch.setattr("stackunderflow.deps.store_path", tmp_path / "store.db") + from stackunderflow.hooks import proactive + from stackunderflow.routes.patterns import DismissRequest, dismiss_pattern + + body = await dismiss_pattern(DismissRequest(type="command-cluster", scope="type")) + assert body["scope"] == "type" + assert body["dismissed"] == "command-cluster" + state = json.loads(proactive._state_path().read_text()) + assert state["feedback"]["command-cluster"]["dismissed"] == 1 + + +@pytest.mark.asyncio +async def test_dismiss_defaults_to_type_scope_without_target(tmp_path, monkeypatch): + monkeypatch.setattr("stackunderflow.deps.store_path", tmp_path / "store.db") + from stackunderflow.routes.patterns import DismissRequest, dismiss_pattern + + # fingerprint scope but no target_key → falls back to a type-scope mute. + body = await dismiss_pattern(DismissRequest(type="file-risk", scope="fingerprint")) + assert body["scope"] == "type" + assert body["dismissed"] == "file-risk" + + +@pytest.mark.asyncio +async def test_dismiss_rejects_unknown_type(tmp_path, monkeypatch): + monkeypatch.setattr("stackunderflow.deps.store_path", tmp_path / "store.db") + from stackunderflow.routes.patterns import DismissRequest, dismiss_pattern + + with pytest.raises(HTTPException) as exc: + await dismiss_pattern(DismissRequest(type="not-a-real-type")) + assert exc.value.status_code == 400 + + +def test_dismiss_route_registered_on_app(): + from stackunderflow.server import app + from tests.conftest import app_route_paths + + assert "/api/patterns/dismiss" in app_route_paths(app)