diff --git a/financial_forecasting/auth.py b/financial_forecasting/auth.py index e0ad7c9f..3ead86f0 100644 --- a/financial_forecasting/auth.py +++ b/financial_forecasting/auth.py @@ -142,6 +142,50 @@ async def get_current_user_dep(request: Request) -> Optional[Dict]: _BEDROCK_INTERNAL_API_KEY = os.getenv("BEDROCK_INTERNAL_API_KEY", "") +# HTTP methods considered "writes" for the kill switch below. Idempotent reads +# (GET, HEAD, OPTIONS) remain available even when the switch is on so that +# Pebble's research/lookup paths keep working during write-side incidents. +_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +# Default scope grant for the internal-key principal. v1.0 grants the +# superset; later phases tighten via per-call X-Pebble-Scopes header. +# `*` is interpreted as "all scopes" by check_permission_or_internal. +_DEFAULT_INTERNAL_SCOPES = ("*",) + + +def _pebble_writes_disabled() -> bool: + """Read the kill switch fresh on every call so operators can flip it + without a redeploy. Truthy values: "true" / "1" / "yes" (case-insensitive). + """ + return os.getenv("PEBBLE_WRITES_DISABLED", "").strip().lower() in {"true", "1", "yes"} + + +def _parse_scopes(header_value: str) -> tuple[str, ...]: + """Parse the optional X-Pebble-Scopes header. Comma-separated scope + strings; empty / unset header = full grant via the wildcard ``*``. + """ + raw = (header_value or "").strip() + if not raw: + return _DEFAULT_INTERNAL_SCOPES + scopes = tuple(s.strip() for s in raw.split(",") if s.strip()) + return scopes or _DEFAULT_INTERNAL_SCOPES + + +def _is_valid_originating_user_email(email: str) -> bool: + """Lightweight shape check on X-Originating-User. We don't verify + membership in org_users here (would require a DB hit on every + request); the route-level audit insert (FK to org_users via + originating_user_email lookup) is the durable check. Spoofs against + the API key still produce auditable rows. + """ + if not email or len(email) > 254: + return False + if "@" not in email or email.startswith("@") or email.endswith("@"): + return False + if any(c.isspace() for c in email): + return False + return True + async def require_auth_or_internal(request: Request) -> Dict: """Authorize via internal API key (service-to-service) or user JWT. @@ -150,13 +194,101 @@ async def require_auth_or_internal(request: Request) -> Dict: synthetic service user dict. Otherwise falls back to require_auth. Dev mode: if BEDROCK_INTERNAL_API_KEY is empty, internal key check is skipped and only JWT auth is tried. + + **Mandatory headers when using internal-key auth (Phase 0.2):** + + - ``X-Originating-User``: the human whose session triggered this + service-to-service call. Required on EVERY internal-key request, + reads and writes alike. The email is propagated to + ``bedrock.pebble_write_audit`` and to permission resolution so + that Pebble acts as a delegated principal, not a god principal. + Three independent adversarial reviews flagged the un-attributed + "service:pebble" pattern as a 1.0 blocker; this enforcement + closes that gap. + - ``X-Request-Id``: a UUIDv7 (or any UUID) for replay defense via + ``UNIQUE(request_id)`` on ``bedrock.pebble_write_audit``. Format + checked here; uniqueness checked at the audit-row INSERT in the + route handler. + + Optional header: + + - ``X-Pebble-Scopes``: comma-separated scopes the caller is + requesting for THIS request. Default = ``("*",)`` (full grant). + ``check_permission_or_internal`` verifies the matching scope is + present. Future tightening: Pebble will request narrow scopes + per call instead of carrying the full grant. + + Kill switch: if PEBBLE_WRITES_DISABLED env is truthy AND the caller + is using a valid internal key AND the request method is a write + (POST/PUT/PATCH/DELETE), return 503. Reads via internal key and all + JWT-authenticated requests are unaffected. """ internal_key = request.headers.get("X-Internal-Key", "") if _BEDROCK_INTERNAL_API_KEY and internal_key: if hmac.compare_digest(internal_key, _BEDROCK_INTERNAL_API_KEY): + if _pebble_writes_disabled() and request.method in _WRITE_METHODS: + logger.warning( + "pebble_writes_disabled: blocking %s %s for service caller", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=503, + detail={ + "error": "pebble_writes_disabled", + "message": ( + "Pebble service-account writes are temporarily " + "disabled. Reads remain available." + ), + }, + ) + + originating_user = request.headers.get("X-Originating-User", "").strip() + if not _is_valid_originating_user_email(originating_user): + logger.warning( + "internal_key_missing_originating_user: %s %s", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=401, + detail={ + "error": "originating_user_required", + "message": ( + "X-Originating-User header is required on every " + "internal-key request. Pebble acts on behalf of a " + "specific user, never as itself." + ), + }, + ) + + request_id = request.headers.get("X-Request-Id", "").strip() + if not request_id: + logger.warning( + "internal_key_missing_request_id: %s %s", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=401, + detail={ + "error": "request_id_required", + "message": ( + "X-Request-Id header is required on every " + "internal-key request for replay defense. " + "Pass a UUID." + ), + }, + ) + + scopes = _parse_scopes(request.headers.get("X-Pebble-Scopes", "")) + return { "user_id": "service:pebble", "email": "pebble@internal", "is_service": True, + "originating_user_email": originating_user, + "request_id": request_id, + "scopes": scopes, } return await require_auth(request) diff --git a/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql b/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql new file mode 100644 index 00000000..1eb8bee0 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql @@ -0,0 +1,150 @@ +-- 2026-05-06: bedrock.pebble_write_audit + bedrock.search_audit_access_log +-- +-- Phase 0.1 + 0.7 of the Pebble 1.0 plan (tasks/pebble-search-spec.md). +-- +-- Why: +-- When Pebble (or any service-to-service caller using +-- X-Internal-Key) writes through Bedrock's API, today's audit +-- trail collapses to a single synthetic user "service:pebble" +-- across logger.info lines and SF "LastModifiedById" — the +-- originating end-user is unrecoverable. Three independent +-- reviews (security/UX/architecture adversaries) flagged this +-- as a 1.0 blocker. +-- +-- bedrock.pebble_write_audit captures every internal-key write +-- with the originating user, request id, route, payload hash, +-- and response status. UNIQUE(request_id) doubles as the +-- idempotency / replay-defense store for X-Request-Id (Phase +-- 0.7) — a duplicate request_id within 24h yields a 409 from +-- the API rather than a duplicate write. +-- +-- bedrock.search_audit_access_log captures admins reading +-- other users' search history when query_text is revealed. +-- Lives separately from the search_audit table itself so that +-- RTBF / DSAR purges of search_audit cannot also erase the +-- accountability trail of who looked at what. +-- +-- Related: +-- * tasks/pebble-search-spec.md (decisions §3, §4) +-- * tasks/pebble-search-spec-security.md §1.5, §2, §10.4 +-- * tasks/pebble-overhaul-plan.md §0.1, §0.7 +-- * financial_forecasting/auth.py:require_auth_or_internal +-- (enforces X-Originating-User; this table receives the +-- audit row from each write route) +-- +-- Idempotent — safe to re-run. +-- +-- Apply as bedrock owner: +-- psql "$DATABASE_URL" -f 2026-05-06-pebble-write-audit.sql + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.pebble_write_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Idempotency / replay defense. Required on every internal-key + -- write; UNIQUE means a 24h replay returns 409 from INSERT. + request_id UUID NOT NULL, + + -- Routing / classification. + route TEXT NOT NULL, -- e.g. "/api/opportunities/update-stage" + http_method TEXT NOT NULL CHECK (http_method IN + ('POST', 'PUT', 'PATCH', 'DELETE')), + + -- Subject. nullable so an early-failure audit row (e.g. invalid + -- payload) still gets written for forensics. + sf_object_type TEXT, -- 'Opportunity' | 'Account' | 'Contact' | 'Task' | 'Payment' | 'Award' | 'Project' + sf_object_id TEXT, + + -- Attribution. originating_user_email is the human whose + -- session triggered the workflow; service_user is "service:pebble" + -- (or future siblings). Both required. + originating_user_email CITEXT NOT NULL, + service_user TEXT NOT NULL DEFAULT 'service:pebble', + + -- What was sent. payload_hash = sha256 of canonical JSON; full + -- payload kept for 90 days where the route opts in (sensitive + -- routes pass NULL to suppress). + payload_hash TEXT, + payload JSONB, + + -- What came back. + response_status SMALLINT NOT NULL, + response_summary JSONB, -- e.g. {"award_created": true, "stage": "..."} + error_class TEXT, -- ExceptionClass name when status >= 400 + + -- Side-effect attestation. ensure_for_opp + future event-bus + -- subscribers write back here so a forensic reader can see the + -- full chain from one INSERT (vs. correlating across 3 tables). + side_effects JSONB, -- e.g. {"award_created": true, "activity_logged": true} + + -- Latency for ops dashboards. + latency_ms INT, + + -- Multi-tenant outer guard. Single value today, present so + -- search_audit / pebble_audit share an enforcement pattern. + org_id TEXT NOT NULL DEFAULT 'pursuit', + + -- Replay defense: the duplicate-request_id detector relies on this. + UNIQUE (request_id) +); + +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_user_time + ON bedrock.pebble_write_audit(originating_user_email, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_object + ON bedrock.pebble_write_audit(sf_object_type, sf_object_id, occurred_at DESC) + WHERE sf_object_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_errors + ON bedrock.pebble_write_audit(occurred_at DESC) + WHERE response_status >= 400; +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_route_time + ON bedrock.pebble_write_audit(route, occurred_at DESC); + +COMMENT ON TABLE bedrock.pebble_write_audit IS + 'Audit row for every internal-key write through Bedrock (Phase 0.1). UNIQUE(request_id) doubles as 24h replay defense (Phase 0.7).'; +COMMENT ON COLUMN bedrock.pebble_write_audit.originating_user_email IS + 'Mandatory. The human whose session triggered the workflow. Sourced from X-Originating-User header. require_auth_or_internal rejects calls without it.'; +COMMENT ON COLUMN bedrock.pebble_write_audit.request_id IS + 'Mandatory. UUIDv7 from X-Request-Id header. Replay window = 24h via UNIQUE constraint; deduplicate at the route via INSERT...ON CONFLICT (request_id) DO NOTHING RETURNING id.'; + +-- --------------------------------------------------------------------------- +-- Admin access log for revealing search_audit.query_text +-- --------------------------------------------------------------------------- +-- search_audit itself comes in a later migration (Layer 1). This table is +-- separate so RTBF/DSAR purges of search_audit cannot also erase +-- accountability. +CREATE TABLE IF NOT EXISTS bedrock.search_audit_access_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + admin_email CITEXT NOT NULL, + target_user_email CITEXT NOT NULL, + revealed_query_text BOOLEAN NOT NULL, + reason TEXT NOT NULL, + request_id UUID NOT NULL, + org_id TEXT NOT NULL DEFAULT 'pursuit' +); + +CREATE INDEX IF NOT EXISTS idx_search_audit_access_admin + ON bedrock.search_audit_access_log(admin_email, accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_search_audit_access_target + ON bedrock.search_audit_access_log(target_user_email, accessed_at DESC); + +COMMENT ON TABLE bedrock.search_audit_access_log IS + 'Append-only log of admins viewing other users search history with query_text revealed. Survives RTBF purges.'; + +-- --------------------------------------------------------------------------- +-- Grants — bedrock_user owns these tables and writes through the API. +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT ON bedrock.pebble_write_audit TO bedrock_user; + GRANT SELECT, INSERT ON bedrock.search_audit_access_log TO bedrock_user; + -- UPDATE intentionally NOT granted on pebble_write_audit: + -- audit rows are append-only (immutability matters for forensics). + -- DELETE limited to a separate retention job role, added when + -- the 90d cleanup script lands. + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-audit.sql b/financial_forecasting/db/migrations/2026-05-06-search-audit.sql new file mode 100644 index 00000000..098953e8 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-audit.sql @@ -0,0 +1,143 @@ +-- 2026-05-06: bedrock.search_audit +-- +-- Layer 1 / security spec §2. Every search query — Find or Ask, +-- human or service — gets one row here. +-- +-- Why: +-- * Compliance / data-access reviews — every query the team +-- makes is recoverable. +-- * Click-through analytics — drives ranking-quality dashboards. +-- * Anomaly detection — exfiltration patterns ("user reading +-- every contact in an hour") are visible to the batch job +-- described in security spec §4. +-- * Debug — "user said they searched X and got Y" is +-- reconstructible. +-- +-- Schema decisions (security spec §2): +-- * query_text stored full at v1.0, length-capped 256 chars at +-- the API. Default dashboards key on query_text_hash; raw +-- query_text reads gated on `manage_users_roles` and +-- themselves audited via bedrock.search_audit_access_log +-- (already shipped in 2026-05-06-pebble-write-audit.sql). +-- * 90 days hot in this table; older rows archived to GCS +-- monthly via Parquet (separate retention job). +-- * Two attribution columns: user_email is the bearer (e.g. +-- pebble@internal for service callers); originating_user_email +-- is the human whose session triggered Pebble. Both required +-- when caller is service. +-- * org_id outer guard from day 1 even though Pursuit is single +-- tenant today. +-- +-- Idempotent — safe to re-run. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.search_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Correlation. query_id groups one query → its results → its + -- click. request_id is the per-HTTP-call idempotency key + -- (Phase 0.7) so a retried search query shows up as one row, + -- not duplicates. + query_id UUID NOT NULL, + request_id UUID NOT NULL, + + -- Attribution. + user_email CITEXT NOT NULL, -- bearer + originating_user_email CITEXT, -- delegated principal when bearer is service:* + org_id TEXT NOT NULL DEFAULT 'pursuit', + + -- The query. + mode TEXT NOT NULL CHECK (mode IN ('find','ask','past_research')), + query_text TEXT NOT NULL, + query_text_hash TEXT NOT NULL, -- sha256; default dashboard join key + facets JSONB, + types_requested TEXT[] DEFAULT '{}', -- entity_type filter; empty = all + + -- Backend served. + backend_used TEXT NOT NULL CHECK (backend_used IN + ('postgres_fts','pgvector','sf_sosl', + 'sf_sosl_fallback','cache_hit','degraded_empty')), + -- Per-backend latency breakdown for SLO drill-down. + perm_resolution_ms INT, + backend_latency_ms INT, + latency_ms INT NOT NULL, -- end-to-end including auth + serialize + + -- Result set. + result_count INT NOT NULL, -- visible to user + result_count_redacted INT, -- before permission redaction; >= result_count + response_status SMALLINT NOT NULL, + error_class TEXT, -- ExceptionClass when status >= 400 + + -- Click attribution. Updated by /api/search/click; left NULL + -- when no click happened. NULL is itself a metric (0-click + -- queries = poor ranking signal). + click_position SMALLINT, + click_entity_type TEXT, + click_record_id TEXT, + click_at TIMESTAMPTZ, + + -- Cost (Ask mode only). Mirrors pebble_daily_usage shape so + -- per-user budget enforcement reads from one source. + cost_usd NUMERIC(10, 6), + tokens_in INT, + tokens_out INT, + + -- Replay defense via UNIQUE on request_id, mirroring the + -- pebble_write_audit pattern. Safe across the 24h replay + -- window. + UNIQUE (request_id) +); + +-- Per-user history (drives /api/search/history and "user X did Y" lookups). +CREATE INDEX IF NOT EXISTS idx_search_audit_user_time + ON bedrock.search_audit(user_email, occurred_at DESC); + +-- Per-originating-user history when bearer is service:*. Lets us +-- ask "what has Pebble done on Jac's behalf today?" +CREATE INDEX IF NOT EXISTS idx_search_audit_origin_time + ON bedrock.search_audit(originating_user_email, occurred_at DESC) + WHERE originating_user_email IS NOT NULL; + +-- Hash-keyed dashboards (no PII leak). +CREATE INDEX IF NOT EXISTS idx_search_audit_qhash_time + ON bedrock.search_audit(query_text_hash, occurred_at DESC); + +-- Click correlation. +CREATE INDEX IF NOT EXISTS idx_search_audit_qid + ON bedrock.search_audit(query_id); + +-- 0-click queries (ranking-quality signal). +CREATE INDEX IF NOT EXISTS idx_search_audit_no_click + ON bedrock.search_audit(occurred_at DESC) + WHERE click_position IS NULL; + +-- Pebble-driven activity surface. +CREATE INDEX IF NOT EXISTS idx_search_audit_pebble + ON bedrock.search_audit(originating_user_email, occurred_at DESC) + WHERE user_email = 'pebble@internal'; + +-- Errors for ops dashboards. +CREATE INDEX IF NOT EXISTS idx_search_audit_errors + ON bedrock.search_audit(occurred_at DESC, error_class) + WHERE response_status >= 400; + +COMMENT ON TABLE bedrock.search_audit IS + 'Audit row for every search query (Phase 1.3). Populated by routes/search.py via FastAPI BackgroundTask. UNIQUE(request_id) doubles as 24h replay defense.'; +COMMENT ON COLUMN bedrock.search_audit.originating_user_email IS + 'When user_email is service:* (e.g. pebble@internal), this carries the human whose session triggered the workflow. Required for service callers; null for direct human searches.'; +COMMENT ON COLUMN bedrock.search_audit.result_count_redacted IS + 'Count BEFORE permission redaction. result_count_redacted >= result_count always. Delta is the leak surface — a high-delta-low-result query may indicate a permission misconfig.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE ON bedrock.search_audit TO bedrock_user; + -- UPDATE granted because click attribution post-update on existing + -- rows is the click endpoint's job (only the click_* columns). + -- DELETE intentionally NOT granted — retention via separate role. + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-doc.sql b/financial_forecasting/db/migrations/2026-05-06-search-doc.sql new file mode 100644 index 00000000..c656e385 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-doc.sql @@ -0,0 +1,271 @@ +-- 2026-05-06: bedrock.search_doc + bedrock.search_index_queue +-- +-- Layer 1 of the Pebble 1.0 search system. The denormalized, +-- cross-entity search index that GlobalSearch Find mode and Pebble's +-- Ask-mode tool calls both query against. Replaces the SOSL passthrough +-- at routes/salesforce_search.py. +-- +-- Design — see tasks/pebble-search-spec-backend.md §2 for the full +-- argument. Headlines: +-- +-- * One denormalized table, one row per searchable entity. Cross- +-- entity ranking uses a single query plan vs UNION ALL of N +-- per-entity indexes. +-- * tsvector + GIN on the composed search_vector for lexical match. +-- * pg_trgm + GIN on search_text for typo tolerance ("ed millr" → +-- "Ed Miller"). +-- * pgvector halfvec(768) embedding column reserved for v1.1 +-- semantic-recall overlay; not populated in v1.0. +-- * Permission columns are FK-shaped (owner_sf_id, owner_email, +-- account_sf_id, visibility) so the API layer can compose a +-- pre-filter accessible-id subquery without denormalizing +-- per-user ACL into rows. See backend spec §4 for the rejection +-- of the post-filter and ACL-array alternatives. +-- * Soft-delete via partial index — tombstones in the heap, never +-- in the GIN posting list. +-- * Multi-tenant org_id from day 1, even though Pursuit is single +-- tenant today (security spec §7). +-- +-- search_index_queue is the durable backbone of the indexer worker +-- pattern (backend spec §3). Every entity write enqueues a row; +-- LISTEN/NOTIFY wakes the worker; FOR UPDATE SKIP LOCKED lets us +-- shard later without changing the schema. +-- +-- Idempotent — safe to re-run. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS vector; + +-- --------------------------------------------------------------------------- +-- search_doc +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS bedrock.search_doc ( + -- Composite identity. The 11-value CHECK constraint is + -- intentionally explicit (not ENUM) so adding a new entity_type + -- is a plain ALTER TABLE rather than the multi-step ALTER TYPE + -- dance Postgres requires for enum mutation. + entity_type TEXT NOT NULL CHECK (entity_type IN ( + 'sf_account','sf_contact','sf_opportunity','sf_task','sf_activity', + 'bedrock_project','bedrock_award','bedrock_saved_view', + 'pebble_profile','pebble_chat_conversation','pebble_batch' + )), + entity_id TEXT NOT NULL, + PRIMARY KEY (entity_type, entity_id), + + -- Display projection. The frontend renders these directly; no + -- post-query JOIN to source tables on the read path. + title TEXT NOT NULL, + subtitle TEXT, + href TEXT NOT NULL, + + -- Lexical search. + search_vector TSVECTOR NOT NULL, + search_text TEXT NOT NULL, + + -- Semantic recall layer. Reserved for v1.1; v1.0 leaves this + -- column NULL. Defining it now means we don't have to + -- ALTER TABLE on a 200k-row hot table later. + embedding halfvec(768), + + -- Permission columns. Pre-filter via accessible-id subquery + -- (backend spec §4). owner_sf_id is the SF user id when the + -- entity is SF-mirrored; owner_email is the Bedrock-side + -- owner. account_sf_id is the parent account for permission + -- inheritance (Contact / Opp / Task visible to whoever can + -- see the parent Account, plus their direct owner). + owner_sf_id TEXT, + owner_email CITEXT, + account_sf_id TEXT, + visibility TEXT NOT NULL DEFAULT 'org' + CHECK (visibility IN ('private','team','org')), + + -- Recency signal. Computed at ranking time as + -- ts_rank_cd(...) * exp(-EXTRACT(epoch FROM now() - activity_at) / (86400 * 30)) + -- so half-life is tunable without reindexing. + activity_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + + -- Indexer metadata. source_version is the source row's + -- version (e.g. SF SystemModstamp or bedrock row's updated_at) + -- so the indexer can skip re-indexing when the source hasn't + -- changed since indexed_at. + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source_version TIMESTAMPTZ, + + -- Multi-tenant outer guard. + org_id TEXT NOT NULL DEFAULT 'pursuit' +); + +-- Lexical search index. Partial on deleted_at IS NULL means +-- soft-deleted tombstones never enter the GIN posting list — they +-- still consume heap, but search ignores them. +CREATE INDEX IF NOT EXISTS idx_search_doc_fts + ON bedrock.search_doc USING GIN(search_vector) + WHERE deleted_at IS NULL; + +-- Trigram fuzzy match for typo tolerance. Same partial predicate. +CREATE INDEX IF NOT EXISTS idx_search_doc_trgm + ON bedrock.search_doc USING GIN(search_text gin_trgm_ops) + WHERE deleted_at IS NULL; + +-- Permission filter inputs. +CREATE INDEX IF NOT EXISTS idx_search_doc_owner_sf + ON bedrock.search_doc(owner_sf_id) + WHERE deleted_at IS NULL AND owner_sf_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_search_doc_owner_email + ON bedrock.search_doc(owner_email) + WHERE deleted_at IS NULL AND owner_email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_search_doc_account + ON bedrock.search_doc(account_sf_id) + WHERE deleted_at IS NULL AND account_sf_id IS NOT NULL; + +-- Multi-tenant guard. +CREATE INDEX IF NOT EXISTS idx_search_doc_org + ON bedrock.search_doc(org_id) + WHERE deleted_at IS NULL; + +-- Per-entity-type filtering for facets ("show me only Opps"). +CREATE INDEX IF NOT EXISTS idx_search_doc_entity + ON bedrock.search_doc(entity_type) + WHERE deleted_at IS NULL; + +-- Recency tiebreaker / "recent" scope. +CREATE INDEX IF NOT EXISTS idx_search_doc_activity + ON bedrock.search_doc(activity_at DESC) + WHERE deleted_at IS NULL AND activity_at IS NOT NULL; + +-- pgvector index reserved for v1.1. Created as a no-op now (no +-- rows have embeddings yet) so the schema is fixed-shape before +-- backfill begins. +CREATE INDEX IF NOT EXISTS idx_search_doc_embedding_hnsw + ON bedrock.search_doc USING hnsw (embedding halfvec_cosine_ops) + WHERE embedding IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- search_vector composer trigger. Single weighting policy so the +-- relevance contract is in one place, not scattered across writers. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION bedrock.search_doc_compose_vector() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(NEW.subtitle, '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.search_text, '')), 'C'); + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_search_doc_compose' + ) THEN + CREATE TRIGGER trg_search_doc_compose + BEFORE INSERT OR UPDATE ON bedrock.search_doc + FOR EACH ROW EXECUTE FUNCTION bedrock.search_doc_compose_vector(); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- search_index_queue — durable hand-off from source-table writes +-- to the asynchronous indexer worker. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS bedrock.search_index_queue ( + id BIGSERIAL PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL CHECK (op IN ('upsert','delete')), + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + attempt_count INT NOT NULL DEFAULT 0, + last_error TEXT, + + -- Coalesce: multiple enqueues of the same entity collapse to + -- one row; ON CONFLICT bumps enqueued_at so the worker + -- processes them in FIFO order without missing any. + UNIQUE (entity_type, entity_id, op) +); + +-- Drain index. Worker uses +-- SELECT ... FROM bedrock.search_index_queue +-- ORDER BY enqueued_at ASC FOR UPDATE SKIP LOCKED LIMIT 100 +-- so this index serves both the order-by and the lock-skip. +CREATE INDEX IF NOT EXISTS idx_search_queue_drain + ON bedrock.search_index_queue(enqueued_at); + +-- Failed-row index. Periodic reconciliation reads attempt_count > 0 +-- to surface stuck rows. +CREATE INDEX IF NOT EXISTS idx_search_queue_stuck + ON bedrock.search_index_queue(attempt_count, enqueued_at) + WHERE attempt_count > 0; + +-- --------------------------------------------------------------------------- +-- Generic enqueue trigger. Source-table triggers reference this +-- with a TG_ARGV[0] entity_type tag. Bedrock-native source tables +-- get one CREATE TRIGGER apiece in subsequent migrations. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION bedrock.enqueue_search_index() RETURNS trigger AS $$ +DECLARE + et TEXT := TG_ARGV[0]; + eid TEXT; + op_val TEXT; +BEGIN + IF TG_OP = 'DELETE' THEN + eid := OLD.id::TEXT; + op_val := 'delete'; + ELSE + eid := NEW.id::TEXT; + op_val := 'upsert'; + END IF; + + INSERT INTO bedrock.search_index_queue (entity_type, entity_id, op) + VALUES (et, eid, op_val) + ON CONFLICT (entity_type, entity_id, op) DO UPDATE + SET enqueued_at = now(), attempt_count = 0, last_error = NULL; + + -- LISTEN/NOTIFY wakes the worker; payload includes the key so a + -- cleverer worker could skip the SELECT for the hot path. + PERFORM pg_notify('bedrock_search_index_queue', et || ':' || eid); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- --------------------------------------------------------------------------- +-- updated_at trigger reuses the existing helper. +-- --------------------------------------------------------------------------- + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_search_doc_updated_at' + ) THEN + CREATE TRIGGER trg_search_doc_updated_at + BEFORE UPDATE ON bedrock.search_doc + FOR EACH ROW EXECUTE FUNCTION bedrock.set_updated_at(); + END IF; +END $$; + +COMMENT ON TABLE bedrock.search_doc IS + 'Denormalized cross-entity search index (Phase 1.1). One row per searchable entity; populated by the queue-drain indexer worker. Permission filtering is pre-filter via accessible-id subquery.'; +COMMENT ON COLUMN bedrock.search_doc.embedding IS + 'pgvector halfvec(768) for v1.1 semantic-recall overlay. NULL in v1.0 — schema reserved so backfill never has to ALTER.'; +COMMENT ON TABLE bedrock.search_index_queue IS + 'Durable queue from source-table writes to the indexer worker. ON CONFLICT coalesces duplicates; LISTEN/NOTIFY on bedrock_search_index_queue wakes the drain.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON bedrock.search_doc TO bedrock_user; + GRANT SELECT, INSERT, UPDATE, DELETE ON bedrock.search_index_queue TO bedrock_user; + GRANT USAGE ON SEQUENCE bedrock.search_index_queue_id_seq TO bedrock_user; + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql b/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql new file mode 100644 index 00000000..618b6e86 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql @@ -0,0 +1,82 @@ +-- 2026-05-06: source-table triggers wiring bedrock entities to the +-- search index queue. +-- +-- Layer 1.6 of the Pebble 1.0 plan. Adds AFTER-INSERT/UPDATE/DELETE +-- triggers on the bedrock-side source tables so every write enqueues +-- a search_index_queue row. The indexer worker drains the queue and +-- composes search_doc rows. +-- +-- Sources wired in this migration: +-- * bedrock.project → entity_type 'bedrock_project' +-- * bedrock.saved_view → entity_type 'bedrock_saved_view' +-- * bedrock.pebble_research_sessions → entity_type 'pebble_profile' +-- (only when status='completed' — +-- in-progress sessions don't +-- appear in search) +-- +-- SF-mirrored sources (Account, Contact, Opportunity, Task, Activity) +-- get wired in a separate migration once the SF mirror tables exist +-- (Layer 1.4). Award (bedrock.award) is wired here too — name is +-- composed from the linked Opportunity which we don't yet mirror; for +-- v1 the composer can defer until Layer 1.4 lands. +-- +-- Idempotent — DROP TRIGGER IF EXISTS before each CREATE. + +-- --------------------------------------------------------------------------- +-- bedrock.project +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_project_search_index ON bedrock.project; +CREATE TRIGGER trg_project_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.project + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_project'); + +-- --------------------------------------------------------------------------- +-- bedrock.saved_view +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_saved_view_search_index ON bedrock.saved_view; +CREATE TRIGGER trg_saved_view_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.saved_view + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_saved_view'); + +-- --------------------------------------------------------------------------- +-- bedrock.pebble_research_sessions +-- +-- Only enqueue when status transitions into 'completed' OR for +-- already-completed rows on UPDATE. In-progress sessions don't appear +-- in search; the composer would return None and we'd waste a queue +-- cycle. Use a WHEN clause to short-circuit at the trigger. +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_pebble_research_sessions_search_index ON bedrock.pebble_research_sessions; +CREATE TRIGGER trg_pebble_research_sessions_search_index + AFTER INSERT OR UPDATE ON bedrock.pebble_research_sessions + FOR EACH ROW + WHEN (NEW.status = 'completed') + EXECUTE FUNCTION bedrock.enqueue_search_index('pebble_profile'); + +-- DELETE needs its own trigger since OLD-only refs aren't valid in +-- the WHEN clause for AFTER-DELETE (NEW is null). +DROP TRIGGER IF EXISTS trg_pebble_research_sessions_search_delete ON bedrock.pebble_research_sessions; +CREATE TRIGGER trg_pebble_research_sessions_search_delete + AFTER DELETE ON bedrock.pebble_research_sessions + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('pebble_profile'); + +-- --------------------------------------------------------------------------- +-- bedrock.award +-- +-- The composer for entity_type='bedrock_award' isn't shipped yet +-- (needs Opp.Name from a future SF mirror). We still wire the trigger +-- so by the time the composer lands, the queue has every existing +-- award row already enqueued — no separate backfill pass needed. +-- +-- Until the composer is registered, the indexer logs +-- 'no_composer' and leaves the queue row in place (silent skip, +-- no failure bump). Confirmed in test_drain_once_no_composer_leaves_queue_intact. +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_award_search_index ON bedrock.award; +CREATE TRIGGER trg_award_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.award + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_award'); diff --git a/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql b/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql new file mode 100644 index 00000000..7f1c924d --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql @@ -0,0 +1,100 @@ +-- 2026-05-06: bedrock.sf_picklist_cache +-- +-- Phase 0.9 of the Pebble 1.0 plan. Single source of truth for +-- Salesforce picklist values (Opportunity.StageName today; extends +-- to other picklists as needed) so that the eight independent +-- locations carrying renamed stage strings (`models.py`, +-- `frontend-v2/lib/stages.ts`, `funnelStages.ts`, +-- `StageProgression.tsx`, `Cleanup.tsx`, `crm_parser.py`, +-- `opportunities_extra.py:53`, `awards_service.ELIGIBLE_STAGES_BY_RECORD_TYPE`) +-- collapse into one fetch. +-- +-- Why: +-- Per `feedback_sf_stages_sacred.md`: SF stages are sacred — +-- never hide/deprecate/reclassify them on the SF side. But +-- stages DO get renamed on the SF side (commit 58c360e was +-- "rename SF stages to match new picklist labels"), and when +-- they do, eight files in the Bedrock codebase need +-- hand-edits. This cache is the canonical Bedrock-side +-- reflection of what's in SF — one fetch, everyone uses it. +-- +-- Design: +-- One row per (object, picklist_field, record_type, value). +-- `record_type` is nullable — when null, the value is +-- available across all record types. Refreshed nightly via a +-- background job calling SF describeSObject. 24h TTL on +-- `services/sf_stages.py` reads. +-- +-- Related: +-- * tasks/pebble-search-spec.md decision §6 (multi-tenant column) +-- * tasks/pebble-overhaul-plan.md §0.9 +-- * services/sf_stages.py (next file) +-- * project_stage_schema_drift memory (eight-location problem) +-- +-- Idempotent — safe to re-run. + +CREATE TABLE IF NOT EXISTS bedrock.sf_picklist_cache ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Composite identity + sf_object TEXT NOT NULL, -- 'Opportunity' | 'Account' | ... + field_name TEXT NOT NULL, -- 'StageName' | 'Type' | ... + record_type TEXT, -- 'Philanthropy' | NULL = all + value TEXT NOT NULL, -- the actual SF picklist value + + -- Display + ordering. Mirror of SF metadata so we don't + -- per-page-fetch. Order is important — the funnel display in + -- frontend-v2/lib/funnelStages.ts encodes a left-to-right + -- progression. + label TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Bucket annotations. Reporting semantics live as buckets ON + -- TOP of stages — never replacing them. Encoded as a string + -- array so we can have e.g. + -- ['REVENUE_EARNING','OPEN_PIPELINE'] without joining a + -- second table. The `bedrock` Python service exposes these + -- as Set to match the F1 buckets PR #134 contract. + buckets TEXT[] NOT NULL DEFAULT '{}', + + -- Probability for forecast math. Mirrors `models.py` static + -- table; live picklist values without an entry default to + -- the median bucket prob. + probability SMALLINT, + + -- Sync metadata. Used by `services/sf_stages.py` cache to + -- decide refresh urgency. + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + refresh_after TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'), + + -- Multi-tenant outer guard. + org_id TEXT NOT NULL DEFAULT 'pursuit', + + UNIQUE (org_id, sf_object, field_name, record_type, value) +); + +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_lookup + ON bedrock.sf_picklist_cache(sf_object, field_name, record_type, sort_order) + WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_buckets + ON bedrock.sf_picklist_cache USING GIN (buckets); +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_stale + ON bedrock.sf_picklist_cache(refresh_after) + WHERE is_active = TRUE; + +COMMENT ON TABLE bedrock.sf_picklist_cache IS + 'Canonical Bedrock-side reflection of Salesforce picklist values. services/sf_stages.py is the read API. Refreshed via nightly background job.'; +COMMENT ON COLUMN bedrock.sf_picklist_cache.buckets IS + 'Reporting bucket annotations on top of SF stages. Examples: {"REVENUE_EARNING","OPEN_PIPELINE"}. Buckets layer on top of stages — never replace them.'; +COMMENT ON COLUMN bedrock.sf_picklist_cache.record_type IS + 'NULL means "applies to all record types". Mirrors how SF stores per-RecordType picklist subset rules.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE ON bedrock.sf_picklist_cache TO bedrock_user; + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql b/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql new file mode 100644 index 00000000..54d93fcf --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql @@ -0,0 +1,110 @@ +-- 2026-05-07: bedrock.pebble_chat_scratchpad +-- +-- Externalized state for the Pebble chat orchestrator. Every step of +-- every conversation persists here: plan emission, tool calls, tool +-- results, evaluations, render, conflicts, checkpoints, errors. The +-- table is the foundation for replay, debugging, training-signal +-- extraction, and the human-visible "what is Pebble doing" plan view. +-- +-- Design — see tasks/pebble-bi-architect.md "Schemas" section. Headlines: +-- +-- * One row per step. Tree-shaped via parent_step_id so re-plans +-- branch (the original plan stays for forensics; the re-plan +-- hangs off the failed evaluation step's id). +-- * step_type CHECK constraint enumerates the legal step kinds — +-- same set the orchestrator emits as SSE events to the FE. +-- * tool_name + tool_args + tool_result NULL when the step isn't +-- a tool call (e.g. plan emission, evaluation). +-- * cost_usd + duration_ms drive bounded-autonomy enforcement and +-- post-run cost telemetry. +-- * org_id outermost guard from day 1. user_email is required so +-- the audit + replay surfaces don't have an "unattributed" row. +-- * Indices: (conversation_id, step_number) for replay; (user_email, +-- created_at) for "what has user X asked recently"; partial on +-- errors so the alerting query stays fast. +-- +-- Idempotent. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.pebble_chat_scratchpad ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID NOT NULL, + parent_step_id UUID, -- nullable; root steps point at none + step_number INT NOT NULL, -- monotonic within conversation_id + step_type TEXT NOT NULL CHECK (step_type IN ( + 'plan', + 'tool_call', + 'tool_result', + 'evaluation', + 'render', + 'conflict', + 'checkpoint', + 'error' + )), + + -- Tool details. NULL for non-tool steps. + tool_name TEXT, + tool_args JSONB, + tool_result JSONB, + + -- Plan / evaluation details. JSONB-shaped; readers know how to + -- interpret based on step_type. + payload JSONB, + + -- Resource accounting. + cost_usd NUMERIC(10, 6), + duration_ms INT, + tokens_in INT, + tokens_out INT, + + -- Attribution. user_email is the originating human (Pebble is + -- always delegated). org_id is the multi-tenant outermost guard. + user_email CITEXT NOT NULL, + org_id TEXT NOT NULL DEFAULT 'pursuit', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Replay index: walking a conversation step-by-step. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_conv_step + ON bedrock.pebble_chat_scratchpad(conversation_id, step_number); + +-- Per-user history: drives /pebble/activity surface. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_user_time + ON bedrock.pebble_chat_scratchpad(user_email, created_at DESC); + +-- Tree: child-step lookups when reconstructing branched re-plans. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_parent + ON bedrock.pebble_chat_scratchpad(parent_step_id) + WHERE parent_step_id IS NOT NULL; + +-- Errors / checkpoints: for ops dashboards + anomaly detection. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_errors + ON bedrock.pebble_chat_scratchpad(created_at DESC) + WHERE step_type IN ('error', 'conflict', 'checkpoint'); + +-- Tool-call cost rollup: the daily-cost cap (Phase 0.4 in +-- pebble_proxy.py) reads pebble_daily_usage; the per-conversation +-- cap reads from this index. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_conv_cost + ON bedrock.pebble_chat_scratchpad(conversation_id, cost_usd) + WHERE cost_usd IS NOT NULL; + +COMMENT ON TABLE bedrock.pebble_chat_scratchpad IS + 'Externalized state for Pebble chat orchestrator. One row per step. Replay-able. tasks/pebble-bi-architect.md'; +COMMENT ON COLUMN bedrock.pebble_chat_scratchpad.parent_step_id IS + 'NULL for root steps. Points at the parent step_id when this step is part of a re-plan or branched execution. Tree shape preserves the original failed plan for forensics.'; +COMMENT ON COLUMN bedrock.pebble_chat_scratchpad.step_type IS + 'plan = planner emitted a Plan; tool_call = orchestrator invoked a tool; tool_result = tool returned; evaluation = evaluator scored a response; render = renderer composed final answer; conflict = two tool results disagreed; checkpoint = human-in-loop pause (e.g. propose_write); error = step failed with cause in payload.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT ON bedrock.pebble_chat_scratchpad TO bedrock_user; + -- No UPDATE: scratchpad is append-only. No DELETE: retention + -- job uses a separate role. + END IF; +END $$; diff --git a/financial_forecasting/frontend-v2/.gitignore b/financial_forecasting/frontend-v2/.gitignore index 1e02f89c..fb9e7fab 100644 --- a/financial_forecasting/frontend-v2/.gitignore +++ b/financial_forecasting/frontend-v2/.gitignore @@ -4,10 +4,10 @@ dist-ssr *.local .vite -# TypeScript incremental build cache +# TypeScript composite-build artifacts. ``tsc -b`` (used by ``npm run +# build``) emits these alongside .ts files. Regenerated on every +# build; checking them in causes spurious diffs. *.tsbuildinfo - -# Compiled vite config (tsc -b emits these from vite.config.ts) vite.config.js vite.config.d.ts diff --git a/financial_forecasting/frontend-v2/package.json b/financial_forecasting/frontend-v2/package.json index 41065a6a..d468d774 100644 --- a/financial_forecasting/frontend-v2/package.json +++ b/financial_forecasting/frontend-v2/package.json @@ -9,7 +9,10 @@ "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings=0", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -53,6 +56,12 @@ "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.2", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@testing-library/jest-dom": "^6.7.0", + "jsdom": "^26.1.0" } } diff --git a/financial_forecasting/frontend-v2/src/App.tsx b/financial_forecasting/frontend-v2/src/App.tsx index 034624c2..841024ed 100644 --- a/financial_forecasting/frontend-v2/src/App.tsx +++ b/financial_forecasting/frontend-v2/src/App.tsx @@ -18,6 +18,8 @@ import { TasksPage } from "./pages/Tasks"; import { ContactsPage } from "./pages/Contacts"; import { ContactDetailPage } from "./pages/ContactDetail"; import { LoginPage } from "./pages/Login"; +import { PebblePage } from "./pages/Pebble"; +import { ChiselPage } from "./pages/Chisel"; import { SettingsPage } from "./pages/Settings"; import { CashFlowPage } from "./pages/CashFlow"; import { PlatformIntakePage } from "./pages/PlatformIntake"; @@ -57,6 +59,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/financial_forecasting/frontend-v2/src/components/AppShell.tsx b/financial_forecasting/frontend-v2/src/components/AppShell.tsx index 2c64a724..7d462c94 100644 --- a/financial_forecasting/frontend-v2/src/components/AppShell.tsx +++ b/financial_forecasting/frontend-v2/src/components/AppShell.tsx @@ -16,6 +16,7 @@ import { Link as LinkIcon, Home, MessageSquarePlus, + Hammer, Receipt, } from "lucide-react"; @@ -50,6 +51,20 @@ const NAV_GROUPS = [ // still wired so direct URLs continue to work. ], }, + { + label: "Pebble", + items: [ + // Pebble's home page (Layer B0 of the overhaul plan — port from + // legacy /pebble in v1). Sparkles icon shared with Cleanup; both + // use it semantically (cleanup = magic, pebble = AI). Permission- + // gated by use_pebble_chat / use_pebble_research at the route + // level — sidebar entry is shown for everyone so users discover + // it exists; gated routes show a "request access" panel for + // un-permissioned users (added in B0.6 follow-up). + { to: "/pebble", label: "Ask Pebble", icon: MessageSquarePlus }, + { to: "/chisel", label: "Chisel", icon: Hammer }, + ], + }, { label: "Awards", items: [ diff --git a/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx b/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx new file mode 100644 index 00000000..c499875f --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx @@ -0,0 +1,278 @@ +/** + * GlobalSearch tests — Layer 0.10 baseline + Layer 2.2 dual-mode + * coverage. Asserts: + * + * A. Renders dialog when open, nothing when closed. + * B. Default mode is Find; segmented toggle visible. + * C. ? prefix and / prefix switch into Ask mode and consume the prefix. + * D. Cmd+I toggles between modes without dropping the query. + * E. Empty Find body shows the "Type at least 2 characters" hint. + * F. Find queries hit /api/search after the debounce fires. + * G. Ask body shows the example prompts when query is empty. + * H. Footer "Ask Pebble: " chip appears when Find query >= 2 chars. + * I. Escape closes the modal. + * J. detectModePrefix helper edge cases. + * K. sanitizeToken strips control chars (XSS defense). + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; + +import { GlobalSearch, _internals } from "./GlobalSearch"; + +// Mock our axios wrapper so Find requests don't actually hit the network. +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(async () => ({ + data: { query_id: "q1", items: [], grouped: {}, total_count: 0, backend_used: "postgres_fts", took_ms: 12 }, + })), + }, +})); + +import { api } from "@/lib/api"; + +function renderModal(props: { open: boolean; onClose?: () => void } = { open: true }) { + const onClose = props.onClose ?? vi.fn(); + return { + onClose, + ...render( + + + , + ), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// A. Open / closed +// --------------------------------------------------------------------------- + +describe("open/closed", () => { + it("renders nothing when closed", () => { + const { container } = renderModal({ open: false }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("renders dialog with aria-modal when open", () => { + renderModal(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// B. Default mode + toggle +// --------------------------------------------------------------------------- + +describe("mode toggle", () => { + it("starts in Find mode by default", () => { + renderModal(); + const findRadio = screen.getByRole("radio", { name: /find/i }); + expect(findRadio).toHaveAttribute("aria-checked", "true"); + }); + + it("clicking Ask radio switches mode", async () => { + renderModal(); + const user = userEvent.setup(); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + await user.click(askRadio); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// C. Prefix detection +// --------------------------------------------------------------------------- + +describe("prefix detection", () => { + it("? prefix switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "?why is acme stalling"); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("why is acme stalling"); + }); + + it("/ prefix switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "/find acme"); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// D. Cmd+I toggle preserves query +// --------------------------------------------------------------------------- + +describe("Cmd+I", () => { + it("toggles mode and keeps the query intact", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + expect(input.value).toBe("acme"); + + await user.keyboard("{Meta>}i{/Meta}"); + expect(screen.getByRole("radio", { name: /ask pebble/i })).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("acme"); + + await user.keyboard("{Meta>}i{/Meta}"); + expect(screen.getByRole("radio", { name: /find/i })).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("acme"); + }); +}); + +// --------------------------------------------------------------------------- +// E + F. Find body +// --------------------------------------------------------------------------- + +describe("find body", () => { + it("shows 'Type at least 2 characters' on empty input", () => { + renderModal(); + expect(screen.getByText(/type at least 2 characters/i)).toBeInTheDocument(); + }); + + it("calls /api/search after debounce when query >= 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + await waitFor( + () => { + expect(api.get).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=acme")); + }, + { timeout: 1000 }, + ); + }); + + it("does not call API when query < 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "a"); + // Wait past the debounce window — should still not have fired. + await new Promise((r) => setTimeout(r, 400)); + expect(api.get).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// G. Ask body +// --------------------------------------------------------------------------- + +describe("ask body", () => { + it("shows example prompts when query is empty", async () => { + renderModal(); + const user = userEvent.setup(); + await user.click(screen.getByRole("radio", { name: /ask pebble/i })); + expect(screen.getByText(/Ask Pebble anything about your CRM/i)).toBeInTheDocument(); + expect(screen.getByText(/which open deals are at risk/i)).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// H. Footer Ask chip +// --------------------------------------------------------------------------- + +describe("ask chip", () => { + it("shows 'Ask Pebble: ' when Find has 2+ char query", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + expect(screen.getByText(/Ask Pebble:/i)).toBeInTheDocument(); + expect(screen.getByText("acme")).toBeInTheDocument(); + }); + + it("hides the chip when query < 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "a"); + expect(screen.queryByText(/Ask Pebble:/i)).toBeNull(); + }); + + it("clicking the chip switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "metlife"); + const chip = screen.getByText(/Ask Pebble:/i).closest("button")!; + await user.click(chip); + expect(screen.getByRole("radio", { name: /ask pebble/i })).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// I. Escape closes +// --------------------------------------------------------------------------- + +describe("escape", () => { + it("calls onClose when Escape pressed", async () => { + const { onClose } = renderModal(); + const user = userEvent.setup(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); + + it("calls onClose when backdrop clicked", async () => { + const { onClose } = renderModal(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("global-search-backdrop")); + expect(onClose).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// J + K. Helpers +// --------------------------------------------------------------------------- + +describe("detectModePrefix", () => { + it("returns null mode for plain text", () => { + expect(_internals.detectModePrefix("acme")).toEqual({ mode: null, rest: "acme" }); + }); + + it("strips the leading ?", () => { + expect(_internals.detectModePrefix("?why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("strips the leading /", () => { + expect(_internals.detectModePrefix("/why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("trims left after the prefix", () => { + expect(_internals.detectModePrefix("? why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("handles empty input", () => { + expect(_internals.detectModePrefix("")).toEqual({ mode: null, rest: "" }); + }); +}); + +describe("sanitizeToken", () => { + it("strips control characters", () => { + // Real token without controls passes through unchanged. + expect(_internals.sanitizeToken("hello world")).toBe("hello world"); + }); + + it("returns the same string for plain text", () => { + expect(_internals.sanitizeToken("Acme Corp · Customer")).toBe("Acme Corp · Customer"); + }); + + it("removes binary control bytes", () => { + const dirty = "beforeafter"; + expect(_internals.sanitizeToken(dirty)).toBe("beforeafter"); + }); +}); diff --git a/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx b/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx index 4377ca4a..dd3f4f46 100644 Binary files a/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx and b/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx differ diff --git a/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx b/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx new file mode 100644 index 00000000..bc9ac4f3 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx @@ -0,0 +1,161 @@ +/** + * ChartRenderer — renders a single ChartSpec via Recharts. + * + * Switch on `kind`. Unknown kinds render an inline note rather than + * crashing; backend's JSON-Schema enum on generate_chart's `kind` arg + * makes this case unreachable in practice, but defensive UI > runtime + * crash on protocol drift. + * + * Visual sizing: 100% width × 220px height by default. Caller wraps in + * a container that imposes width — we don't do width: auto inside + * Recharts because its ResponsiveContainer needs a parent width. + * + * Accessibility: `role="img"` + `aria-label` for screen readers since + * Recharts SVGs are visual-only by default. + */ + +import { + Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Funnel, + FunnelChart, LabelList, Legend, Line, LineChart, Pie, PieChart, + ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis, +} from "recharts"; + +import type { ChartSpec } from "@/types/pebble"; + +// Pursuit's neutral palette. Mirrors the ink/surface tokens — keeps +// charts visually consistent with the rest of the app rather than +// Recharts' default red. +const COLORS = ["#1f2937", "#4b5563", "#9ca3af", "#d1d5db", "#374151", "#6b7280"]; +const HEIGHT = 220; + +export function ChartRenderer({ spec }: { spec: ChartSpec }) { + const aria = `${spec.kind} chart: ${spec.title || "untitled"}`; + if (!spec.data || spec.data.length === 0) { + return ( + +

No data for this view.

+
+ ); + } + + return ( + + + {renderInner(spec)} + + + ); +} + +function ChartShell({ + title, aria, children, +}: { + title: string; + aria: string; + children: React.ReactNode; +}) { + return ( +
+ {title ? ( +
+ {title} +
+ ) : null} + {children} +
+ ); +} + +// Returning a single Recharts element (not React.ReactNode) — Recharts' +// ResponsiveContainer requires exactly one child of a chart type. +function renderInner(spec: ChartSpec): React.ReactElement { + const xKey = spec.x_key || "name"; + const yKeys = spec.y_keys.length > 0 ? spec.y_keys : ["value"]; + + switch (spec.kind) { + case "bar": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "line": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "area": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "scatter": + return ( + + + + + + + + ); + case "pie": + return ( + + + + + {spec.data.map((_, i) => ( + + ))} + + + ); + case "funnel": + return ( + + + + + {spec.data.map((_, i) => ( + + ))} + + + ); + } +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx b/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx new file mode 100644 index 00000000..b1d2b209 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx @@ -0,0 +1,48 @@ +/** + * CitationList — numbered footnote list for the citations attached + * to a Pebble final response. + * + * Each citation links to the entity detail page where one exists + * (citation.href is computed server-side in renderer._maybe_build_href). + * Otherwise renders as inert text — defensive against malformed cites. + */ + +import { Link as LinkIcon } from "lucide-react"; +import { Link as RouterLink } from "react-router-dom"; + +import type { Citation } from "@/types/pebble"; + +export function CitationList({ citations }: { citations: Citation[] }) { + if (!citations || citations.length === 0) return null; + + return ( +
+

+ Sources +

+
    + {citations.map((c, i) => ( +
  1. + {i + 1}. + {c.href ? ( + + + {c.title || `${c.entity_type}:${c.entity_id}`} + + ) : ( + + {c.title || `${c.entity_type}:${c.entity_id}`} + + )} +
  2. + ))} +
+
+ ); +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx b/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx new file mode 100644 index 00000000..f237ef28 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx @@ -0,0 +1,361 @@ +/** + * ConversationView — assembles a sequence of PebbleTurns into the + * chat-style center column of the Pebble page. + * + * Each turn renders three vertical zones: + * 1. User query (right-aligned bubble) + * 2. Plan-as-todos card (collapsible, live during streaming) + * 3. Assistant response (final.text + charts + citations) + * + * If the turn is degraded or errored, the response zone shows the + * degradation banner above the text. This is the single place that + * decides "what to show for a finished turn" — every other component + * renders one piece in isolation. + * + * Auto-scroll to the bottom on new turn / new event so users see live + * updates without manual scrolling. + */ + +import { useEffect, useRef, useState } from "react"; +import { AlertTriangle, Loader2, Sparkles } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { CitationList } from "./CitationList"; +import { ChartRenderer } from "./ChartRenderer"; +import { PlanTrace } from "./PlanTrace"; +import type { PebbleTurn } from "@/types/pebble"; + +export function ConversationView({ + turns, isStreaming, collapsed = false, +}: { + turns: PebbleTurn[]; + isStreaming: boolean; + collapsed?: boolean; +}) { + const scrollRef = useRef(null); + // In deck (collapsed) mode, track which card the user has clicked + // to "draw" out of the stack. Default null = compact deck only. + const [drawnTurnId, setDrawnTurnId] = useState(null); + + // Auto-scroll to bottom on turn count change OR while streaming + // (only in expanded mode — deck mode keeps the latest card on top + // anyway, no scroll needed). + useEffect(() => { + if (collapsed) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [turns.length, isStreaming, collapsed]); + + // When the deck switches between collapsed/expanded, drop any + // previously "drawn" card so we start fresh. + useEffect(() => { + setDrawnTurnId(null); + }, [collapsed]); + + if (turns.length === 0) { + return ; + } + + if (collapsed) { + return ( + + ); + } + + return ( +
+
+ {turns.map((turn) => ( + + ))} +
+
+ ); +} + +function EmptyState({ collapsed }: { collapsed: boolean }) { + // In collapsed mode the deck area is narrow; keep the empty-state + // copy short so it fits without wrapping awkwardly. + if (collapsed) { + return ( +
+

No turns yet — ask below.

+
+ ); + } + return ( +
+
+
+
+

Ask Pebble

+

+ Ask about accounts, opportunities, or anything in the CRM. Try{" "} + /pipeline{" "} + for a weekly review. +

+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Deck-of-cards collapsed view. +// +// Each turn becomes a compact card. Cards stack vertically with a small +// vertical offset so the user can see the edge of every card in the +// stack — JP's spec: "don't hide it when collapsed". Newest turn sits +// on top of the deck (visually frontmost). Click any card to "draw" it +// out of the stack — the drawn card expands inline to show the full +// turn (plan, response, charts, citations) while the surrounding cards +// stay visible above and below. +// +// Why offset stacking instead of a flat list: +// * Visually communicates "this is a deck" — turns feel like a +// physical stack the user can flip through. +// * Compact mode meaningfully shrinks the conversation surface so +// the MessageInput area + sidebar can dominate. A flat list of +// cards would just be the expanded view with smaller cards. +// --------------------------------------------------------------------------- + +const CARD_PEEK_PX = 8; // how much each card peeks above the next +const CARD_HEIGHT_PX = 56; // compact card height when undrawn + +function DeckView({ + turns, drawnTurnId, onDraw, +}: { + turns: PebbleTurn[]; + drawnTurnId: string | null; + onDraw: (id: string | null) => void; +}) { + // Newest turn at the top of the visible stack (matches chat + // convention of "most recent up top in collapsed view"). + const ordered = [...turns].reverse(); + return ( +
+
    + {ordered.map((turn, i) => { + const drawn = drawnTurnId === turn.turn_id; + return ( +
  • 0 && `-mt-[${CARD_HEIGHT_PX - CARD_PEEK_PX * 2}px]`, + )} + style={ + !drawn && i > 0 + ? { marginTop: -(CARD_HEIGHT_PX - CARD_PEEK_PX * 2) } + : undefined + } + > + {drawn ? ( + onDraw(null)} + /> + ) : ( + onDraw(turn.turn_id)} + /> + )} +
  • + ); + })} +
+
+ ); +} + +function DeckCard({ + turn, stackIndex, onClick, +}: { + turn: PebbleTurn; + stackIndex: number; + onClick: () => void; +}) { + const isStreaming = !turn.finished_at; + const isError = Boolean(turn.error); + const isDegraded = Boolean(turn.final?.degraded); + return ( + + ); +} + +function DrawnCard({ + turn, onClose, +}: { + turn: PebbleTurn; + onClose: () => void; +}) { + // The drawn card is essentially a TurnView with a small "tuck back + // into deck" affordance at the top. + return ( +
+
+ + Turn detail + + +
+ +
+ ); +} + +function formatCost(cost: number): string { + if (!cost) return "$0"; + if (cost >= 0.01) return `$${cost.toFixed(2)}`; + return `$${cost.toFixed(4)}`; +} + + +function TurnView({ turn }: { turn: PebbleTurn }) { + const final = turn.final; + const hasError = Boolean(turn.error); + const showStreaming = !turn.finished_at; + + return ( +
+ {/* User query — right-aligned chat bubble */} +
+
+ {turn.query} +
+
+ + {/* Plan-as-todos */} + + + {/* Assistant response */} + {(final || turn.draft || hasError) && ( +
+ {/* Degraded banner */} + {final?.degraded && ( + + )} + {hasError && !final && ( + + )} + + {/* Text */} +
+ {final?.text || turn.draft?.text || ""} + {showStreaming && !final && !turn.draft && ( + + )} +
+ + {/* Charts */} + {(final?.charts || turn.draft?.charts || []).length > 0 && ( +
+ {(final?.charts || turn.draft?.charts || []).map((c) => ( + + ))} +
+ )} + + {/* Citations */} + +
+ )} +
+ ); +} + +function DegradedBanner({ reason }: { reason: string | null }) { + return ( +
+
+ ); +} + +function ErrorBanner({ phase, reason }: { phase?: string; reason?: string }) { + return ( +
+
+ ); +} + +function humanizeReason(raw: string): string { + // Translate a few known degradation_reason strings into something + // user-readable. Unknown strings pass through verbatim. + const map: Record = { + budget_exhausted: "I hit my per-conversation budget", + pre_flight_rejected: "the question was too broad to fit my budget", + empty_tool_results: "the tools didn't find anything", + evaluator_abort: "my safety check flagged the answer", + anthropic_unavailable: "the chat brain is offline", + unknown_workflow_intent: "I don't know that workflow", + }; + return map[raw] ?? raw; +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx b/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx new file mode 100644 index 00000000..1c71d989 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx @@ -0,0 +1,102 @@ +/** + * MessageInput — multi-line textarea + send button + streaming-state + * cancel button. + * + * Behaviors: + * - Enter sends; Shift+Enter inserts newline. + * - Cmd/Ctrl+Enter also sends (matches the "Slack convention"). + * - Send disabled when query empty or while streaming (unless caller + * wires up streaming-time send for parallel turns; we don't in v1). + * - Cancel button visible when streaming; aborts the active turn. + * - Auto-grows up to ~6 lines, then scrolls. + */ + +import { useEffect, useRef } from "react"; +import { ArrowUp, Square } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface Props { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; + isStreaming: boolean; + placeholder?: string; + disabled?: boolean; +} + +export function MessageInput({ + value, onChange, onSubmit, onCancel, isStreaming, + placeholder = "Ask Pebble anything…", + disabled = false, +}: Props) { + const taRef = useRef(null); + + // Auto-grow logic — adjust rows based on content height. + useEffect(() => { + const ta = taRef.current; + if (!ta) return; + ta.style.height = "auto"; + const next = Math.min(ta.scrollHeight, 6 * 22 /* px per row */ + 16); + ta.style.height = `${next}px`; + }, [value]); + + const canSend = value.trim().length > 0 && !disabled && !isStreaming; + + function handleKey(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (canSend) onSubmit(); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (canSend) onSubmit(); + } + } + + return ( +
+