diff --git a/.env.example b/.env.example index 20f0d54..38ed965 100644 --- a/.env.example +++ b/.env.example @@ -84,8 +84,14 @@ CORTEX_API_KEYS= # CORTEX_DEMO_API_KEY=your-preview-demo-key # Dashboard build-time default (overridable in UI). MCP uses CORTEX_API_KEY below. +# Plan A (Cloudflare Pages): set to your Render API URL, e.g. https://cortex-api.onrender.com +VITE_API_URL= VITE_CORTEX_API_KEY= +# Comma-separated browser origins for dashboard (Plan A / cross-origin API). +# Example: https://cortex-dashboard.pages.dev,https://main.cortex-dashboard.pages.dev +CORS_ORIGINS= + # ── MCP Server ─────────────────────────────────────────────────────────────── MCP_PORT=8001 CORTEX_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore index 4a1b295..fb69d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ logs/ tmp/ temp/ .cache/ +.vercel diff --git a/CLAUDE.md b/CLAUDE.md index 8fcb369..da955ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,10 +93,10 @@ LinkedIn: linkedin.com/in/abhinaysai-kamineni ## Current Phase -**Phase:** 4 — Importance scorer + trust scorer + RBAC -**Status:** Phases 2–4 complete on `main`; GDPR erasure API in progress -**Next phase:** Phase 6 — Dashboard polish + demo launch (Phase 5 intelligence modules shipped) -**Target:** Working MVP demo in 4 weeks +**Phase:** 7 — Demo launch + open-source polish +**Status:** Phase 6 dashboard stack complete on `feat/ui-qa-a11y`; merge to `main` in progress +**Next phase:** Phase 8 — Outcome tracking + coverage scoring (post-launch) +**Target:** Working public demo URL + README launch --- @@ -110,7 +110,7 @@ LinkedIn: linkedin.com/in/abhinaysai-kamineni | 3 | `cortex.query()` API + Redis cache + MCP server | Week 2 | ✅ Done | | 4 | Importance scorer + trust scorer + RBAC | Week 2-3 | ✅ Done | | 5 | Contradiction detector + decay engine | Week 3 | ✅ Done | -| 6 | React dashboard + knowledge graph explorer | Week 3-4 | ⏳ | +| 6 | React dashboard + knowledge graph explorer | Week 3-4 | ✅ Done | | 7 | Demo video + README polish + open-source launch | Week 4 | ⏳ | | 8 | Outcome tracking + coverage scoring | Post-launch | ⏳ | | 9 | Behavioral mining + elicitation bot | Post-launch | ⏳ | diff --git a/README.md b/README.md index 12482eb..6bf4069 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,17 @@ Regenerate the README animation: `python scripts/generate_readme_demo_gif.py` --- +## Deploy + +| Goal | Guide | +|------|--------| +| **$0 portfolio demo** (Cloudflare Pages + Render free + Aura + Upstash) | [docs/DEPLOY-FREE.md](docs/DEPLOY-FREE.md) | +| **Production split** (Vercel dashboard + Railway/Render API) | [docs/DEPLOY.md](docs/DEPLOY.md) | + +After deploy, verify end-to-end: `./scripts/verify_free_deploy.sh --api https://YOUR_API --pages https://YOUR_PAGES` + +--- + ## Architecture ``` @@ -375,7 +386,7 @@ cortex/ | Phase 4 | Importance + trust scoring + graph RBAC | ✅ Shipped | | Phase 5 | Contradiction detector + decay engine | ✅ Shipped | | Phase 6 | React dashboard (Ask, memory map, guide, agent inject) | ✅ Shipped | -| Phase 7 | Demo video + open-source launch polish | 🔄 README GIF + pre-launch hardening shipped | +| Phase 7 | Live demo URL + demo video + open-source launch | 🔄 In progress | | Phase 8 | Outcome tracking + coverage scoring | ⏳ Post-launch | | Phase 9 | Elicitation bot (implicit knowledge) | ⏳ Post-launch | | Phase 10 | Federated cross-org memory | ⏳ v2 | diff --git a/SESSIONS.md b/SESSIONS.md index bee5e17..d734af8 100644 --- a/SESSIONS.md +++ b/SESSIONS.md @@ -528,3 +528,30 @@ 1. Merge PR #15 after CI green 2. Phase 6: dashboard polish + demo video 3. Staging smoke with `CORTEX_CMVK_BACKEND=openai` + +--- + +## Session — 2026-06-25 — UI stack completion + main merge prep +**Duration:** ~3h (stacked PRs #18–#24, e2e fixes, rebase) +**Phase:** Phase 6 complete → Phase 7 launch prep + +### Built +- **PRs #18–#24** merged stack-to-stack; **PR #18 only** on `main` until follow-up PR +- **PR #17** closed (superseded by reliability + Plan A deploy path) +- E2E fix: `journeys.spec.ts` assertion `1 result` → `1 shown` (AskView copy change) +- Stacked PR build fixes: AppContext wiring, DecisionCard `onDetail`, rebase chain +- **`docs/DEPLOY-FREE.md`** — Plan A $0 runbook (Cloudflare Pages + Render + Aura + Upstash) +- **PR to `main`** opened from `feat/ui-qa-a11y` (full dashboard + deploy) + +### State at end +- **`main`:** PR #18 only (reliability foundation) +- **`feat/ui-qa-a11y`:** full product tip — 11 vitest + 8 e2e pass locally +- **Open PR:** `feat/ui-qa-a11y` → `main` (awaiting merge + CI) + +### Blockers +- None for merge; Plan A deploy requires manual Aura/Render/Cloudflare setup (no secrets in repo) + +### Next session starts with +1. Merge `feat/ui-qa-a11y` → `main` PR when CI green +2. Execute Plan A deploy per `docs/DEPLOY-FREE.md` +3. Phase 7: live demo URL in README + 3-minute demo video diff --git a/docs/DEPLOY-FREE.md b/docs/DEPLOY-FREE.md new file mode 100644 index 0000000..69e4236 --- /dev/null +++ b/docs/DEPLOY-FREE.md @@ -0,0 +1,228 @@ +# Plan A — $0 deployment (Cloudflare Pages + Render + Aura + Upstash) + +Portfolio demo at **$0/month**: dashboard 24/7 on Cloudflare Pages, API on Render free tier (cold start after idle), Neo4j Aura Free, Upstash Redis Free. **No Kafka** — seed the graph directly. + +For the full paid/split deploy map see [DEPLOY.md](./DEPLOY.md). + +--- + +## Architecture + +```mermaid +flowchart LR + subgraph free_always [Always on — $0] + CF[Cloudflare Pages\nfrontend/] + end + subgraph free_sleep [Free — may sleep] + R[Render Web Service\napi/Dockerfile] + end + subgraph managed [Managed free tiers] + N[Neo4j Aura Free] + U[Upstash Redis] + end + CF -->|VITE_API_URL| R + R --> N + R --> U +``` + +| Component | Platform | Cost | +|-----------|----------|------| +| Dashboard | Cloudflare Pages | $0 | +| API | Render (free plan) | $0 | +| Graph | Neo4j Aura Free | $0 | +| Cache | Upstash Redis | $0 | +| Kafka / worker | Skipped | — | + +**Tradeoff:** Render free services **sleep after ~15 minutes** of no traffic. First request after idle may take 30–60 seconds. The dashboard stays fast; only API calls wait. + +--- + +## Phase 1 — Data layer (~15 min) + +### 1. Neo4j Aura Free + +1. [neo4j.com/cloud/aura-free](https://neo4j.com/cloud/aura-free/) → create instance +2. Save `NEO4J_URI` (`neo4j+s://…`), user `neo4j`, password + +### 2. Upstash Redis + +1. [console.upstash.com](https://console.upstash.com) → Create Redis database +2. Copy `REDIS_URL` (`rediss://…`) + +### 3. Seed from your laptop (recommended) + +```bash +export NEO4J_URI=neo4j+s://xxxx.databases.neo4j.io +export NEO4J_USER=neo4j +export NEO4J_PASSWORD=your-password + +uv run python graph/migrate.py +uv run python scripts/seed_demo.py --workspace local-dev --scale small +``` + +Optional second workspace (dual-workspace demo): + +```bash +uv run python scripts/import_github_graph.py \ + --org tiangolo --repo fastapi \ + --workspace oss-tiangolo-fastapi --limit 30 +``` + +--- + +## Phase 2 — API on Render (~20 min) + +### Option A — Blueprint (repo includes `render.yaml`) + +1. [dashboard.render.com](https://dashboard.render.com) → **New** → **Blueprint** +2. Connect `askmy-stack/cortex` (use branch `main` after merge, or your feature branch) +3. Render reads [`render.yaml`](../render.yaml) + +### Option B — Manual Web Service + +1. **New** → **Web Service** → connect repo +2. **Runtime:** Docker +3. **Dockerfile path:** `api/Dockerfile` +4. **Docker context:** `.` (repo root) +5. **Plan:** Free + +### Required environment variables (Render → Environment) + +| Variable | Value | +|----------|--------| +| `NEO4J_URI` | Aura URI | +| `NEO4J_USER` | `neo4j` | +| `NEO4J_PASSWORD` | Aura password | +| `REDIS_URL` | Upstash URL | +| `CORTEX_SEMANTIC_ENABLED` | `false` | +| `EXTRACTION_BACKEND` | `heuristic` | +| `CORTEX_SEED_DEMO` | `true` on **first** deploy only, then `false` | +| `CORS_ORIGINS` | Your Cloudflare Pages URL (see Phase 3) | +| `CORTEX_API_KEYS` | optional, e.g. `demo-readonly:authenticated` | + +After deploy, copy the public URL: `https://cortex-api-xxxx.onrender.com` + +### Verify API + +```bash +export API=https://cortex-api-xxxx.onrender.com +curl -s "$API/health" | jq . +curl -s -X POST "$API/query" \ + -H "Content-Type: application/json" \ + -d '{"query":"Why CockroachDB?","workspace_id":"local-dev","limit":5}' | jq . +``` + +Or run [`scripts/verify_free_deploy.sh`](../scripts/verify_free_deploy.sh) (see below). + +--- + +## Phase 3 — Dashboard on Cloudflare Pages (~15 min) + +Cloudflare Pages does **not** run Vercel Edge Middleware. Point the dashboard at Render with a **build-time** env var. + +1. [dash.cloudflare.com](https://dash.cloudflare.com) → **Workers & Pages** → **Create** → **Pages** → **Connect to Git** +2. Select `askmy-stack/cortex` +3. **Build settings:** + +| Setting | Value | +|---------|--------| +| Production branch | `main` (after merge) | +| Root directory | `frontend` | +| Build command | `npm run build` | +| Build output directory | `dist` | + +4. **Environment variables** (Production + Preview): + +| Variable | Value | +|----------|--------| +| `VITE_API_URL` | `https://cortex-api-xxxx.onrender.com` (no trailing slash) | +| `VITE_CORTEX_API_KEY` | optional — same as `CORTEX_API_KEYS` if auth enabled | + +5. **Deploy** → URL: `https://cortex-dashboard.pages.dev` (or custom subdomain) + +### CORS on Render + +Set `CORS_ORIGINS` on the API to your Pages URL: + +```text +https://cortex-dashboard.pages.dev,https://your-preview.pages.dev +``` + +Redeploy API after changing CORS. + +### CLI alternative (optional) + +From `frontend/` with [Wrangler](https://developers.cloudflare.com/workers/wrangler/) installed: + +```bash +cd frontend +npm run build +npx wrangler pages deploy dist --project-name=cortex-dashboard +``` + +See [`frontend/wrangler.toml`](../frontend/wrangler.toml). + +--- + +## Phase 4 — Merge PR stack to `main` + +Deploy from `main` for stable URLs. Merge order: + +1. [#18](https://github.com/askmy-stack/cortex/pull/18) Reliability +2. [#19](https://github.com/askmy-stack/cortex/pull/19) Consistency +3. [#20](https://github.com/askmy-stack/cortex/pull/20) Onboarding +4. [#21](https://github.com/askmy-stack/cortex/pull/21) Ask + Home +5. [#22](https://github.com/askmy-stack/cortex/pull/22) Explore +6. [#23](https://github.com/askmy-stack/cortex/pull/23) QA + a11y +7. [#24](https://github.com/askmy-stack/cortex/pull/24) Plan A free deploy *(after this PR is opened)* + +Close [#17](https://github.com/askmy-stack/cortex/pull/17) once the UI stack + Plan A land on `main` (superseded). + +Point Cloudflare Pages and Render at `main` after merge. + +--- + +## Verification checklist + +- [ ] `curl $API/health` → `status: ok`, `neo4j` + `redis` ok +- [ ] `POST $API/query` → ≥1 decision for `local-dev` +- [ ] Pages URL loads Home without white screen +- [ ] Ask search returns results (may wait for API cold start once) +- [ ] Connection panel accepts API key if `CORTEX_API_KEYS` is set + +```bash +./scripts/verify_free_deploy.sh \ + --api https://cortex-api-xxxx.onrender.com \ + --pages https://cortex-dashboard.pages.dev +``` + +--- + +## README one-liner (portfolio) + +```markdown +**Live demo:** https://your-project.pages.dev +*(API on Render free tier — first load after idle may take ~30s)* +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| CORS error in browser | Set `CORS_ORIGINS` on Render to exact Pages origin (`https://…pages.dev`) | +| Query 502 / timeout | Render service sleeping — retry after 30–60s | +| Empty Ask results | Re-run `seed_demo.py` or set `CORTEX_SEED_DEMO=true` once on Render | +| Vercel Python 5 GB bundle | Use Cloudflare Pages with Root Directory `frontend` instead | +| `neo4j` unhealthy | Check Aura URI, password, IP allowlist (Aura allows all by default) | + +--- + +## What stays local ($0 cloud cannot run) + +- Kafka + `pipeline-worker` live ingestion +- Full `docker-compose` observability stack +- Semantic/Qdrant search (`CORTEX_SEMANTIC_ENABLED=false`) + +Use `make demo` locally for the full stack story; use Plan A for the public portfolio URL. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 360e8c1..ba70d11 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,12 +1,14 @@ # Deploying Cortex -Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, dashboard). Vercel hosts the **dashboard only**; the API and worker run elsewhere. +Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, dashboard). The **dashboard** is static (Vercel or Cloudflare Pages); the **API** runs on Render/Railway/etc. + +> **$0 portfolio deploy:** see **[DEPLOY-FREE.md](./DEPLOY-FREE.md)** — Cloudflare Pages + Render free + Aura + Upstash (no Kafka). ## Recommended split | Component | Host | Notes | |-----------|------|-------| -| Dashboard | **Vercel** (`frontend/`) | Vite static + API rewrites | +| Dashboard | **Cloudflare Pages** or **Vercel** (`frontend/`) | CF: `VITE_API_URL`; Vercel: `CORTEX_API_ORIGIN` middleware | | API | Railway / Render / Fly | `api/Dockerfile` | | pipeline-worker | Same as API | `pipeline/Dockerfile` | | Neo4j | Neo4j Aura | Bolt URI in env | diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..cfa7d56 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# Build-time API URL for Cloudflare Pages / static hosts without edge proxy. +# Leave empty for local dev (Vite proxy) or Vercel with CORTEX_API_ORIGIN middleware. +VITE_API_URL= + +# Optional default key shown in Connection panel (overridable in UI). +VITE_CORTEX_API_KEY= diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts index 5aa63e8..54c3622 100644 --- a/frontend/e2e/a11y.spec.ts +++ b/frontend/e2e/a11y.spec.ts @@ -11,10 +11,10 @@ test.describe("Accessibility audit", () => { test("home view has no serious axe violations", async ({ page }) => { await page.goto("/"); await expect(page.locator("main#main")).toBeVisible(); + // Let entrance animations finish so axe does not sample faded text. + await page.waitForTimeout(700); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).analyze(); const serious = results.violations.filter( (v) => v.impact === "serious" || v.impact === "critical", diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index a77533e..bb8aad2 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -83,5 +83,9 @@ export async function installApiMocks(page: Page): Promise { export async function dismissOnboarding(page: Page): Promise { await page.addInitScript(() => { localStorage.setItem("cortex_onboarding_v1", "done"); + localStorage.setItem( + "cortex_settings_v2", + JSON.stringify({ onboardingComplete: true, workspaceId: "local-dev" }), + ); }); } diff --git a/frontend/e2e/journeys.spec.ts b/frontend/e2e/journeys.spec.ts index 60169ae..60d8f59 100644 --- a/frontend/e2e/journeys.spec.ts +++ b/frontend/e2e/journeys.spec.ts @@ -39,7 +39,9 @@ test.describe("Critical user journeys", () => { await page.goto("/#ask"); await page.getByLabel(/your question/i).fill("Why CockroachDB for payments?"); await page.getByRole("button", { name: /search memory/i }).click(); - await expect(page.getByRole("heading", { name: /1 result/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /1 shown/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole("button", { name: /open memory map/i }).click(); await expect(page.getByRole("heading", { name: /memory map/i })).toBeVisible(); @@ -56,4 +58,20 @@ test.describe("Critical user journeys", () => { await skip.focus(); await expect(skip).toBeFocused(); }); + + test("demo journey: ask search, explore, connection panel", async ({ page }) => { + await page.goto("/#ask"); + await expect(page.getByRole("heading", { name: /ask your organization/i })).toBeVisible(); + + await page.getByLabel(/your question/i).fill("Why CockroachDB for payments?"); + await page.getByRole("button", { name: /search memory/i }).click(); + await expect(page.getByText(/1 shown/i)).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: /open memory map/i }).click(); + await expect(page.getByRole("heading", { name: /memory map/i })).toBeVisible(); + + await page.getByRole("button", { name: "Connection", exact: true }).click(); + await expect(page.getByLabel(/^api key$/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /test connection/i })).toBeVisible(); + }); }); diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000..ad37e2c --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3bea1ef..5461cae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy, useState } from "react"; +import { Suspense, lazy } from "react"; import { AppProvider, useApp } from "./context/AppContext"; import { ToastProvider } from "./components/ui/Toast"; import { ErrorBoundary } from "./components/ui/ErrorBoundary"; @@ -11,9 +11,9 @@ import { AskView } from "./views/AskView"; import { SkeletonStack } from "./components/ui/Skeleton"; import { apiBase } from "./api/client"; import { resolveApiKey } from "./lib/auth"; -import { hasCompletedOnboarding } from "./lib/onboarding"; import { BugReportSection } from "./components/layout/BugReportSection"; import { useApiHealth } from "./hooks/useApiHealth"; +import { IconSpark } from "./components/ui/icons"; const ExploreView = lazy(() => import("./views/ExploreView").then((m) => ({ default: m.ExploreView })), @@ -102,7 +102,7 @@ function TopbarActions() { onClick={() => setAssistantOpen(true)} aria-label="Open Cortex Assist" > - ✦ Assist + Assist typeof window !== "undefined" && !hasCompletedOnboarding(), - ); + const { setAssistantOpen, setView, setWorkspaceId, showOnboarding, setShowOnboarding } = + useApp(); return (
@@ -136,6 +134,10 @@ function AppChrome() { setShowOnboarding(false)} onOpenCopilot={() => setAssistantOpen(true)} + onFinishAsk={(workspace, query) => { + setWorkspaceId(workspace); + setView("ask", { q: query }); + }} /> ) : null} diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index ea705ee..08b31d3 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -1,12 +1,20 @@ import type { ViewId } from "../../types"; import { useApp } from "../../context/AppContext"; +import { + IconAgent, + IconGraph, + IconHome, + IconReview, + IconSearch, + IconSpark, +} from "../ui/icons"; -const ITEMS: { id: ViewId; label: string; icon: string }[] = [ - { id: "home", label: "Overview", icon: "⌂" }, - { id: "ask", label: "Search", icon: "?" }, - { id: "explore", label: "Map", icon: "◎" }, - { id: "agents", label: "AI", icon: "⚡" }, - { id: "review", label: "Review", icon: "⚖" }, +const ITEMS: { id: ViewId; label: string; Icon: typeof IconHome }[] = [ + { id: "home", label: "Overview", Icon: IconHome }, + { id: "ask", label: "Search", Icon: IconSearch }, + { id: "explore", label: "Map", Icon: IconGraph }, + { id: "agents", label: "AI", Icon: IconAgent }, + { id: "review", label: "Review", Icon: IconReview }, ]; /** Bottom tab bar for phone/tablet — primary navigation on small screens. */ @@ -22,9 +30,10 @@ export function MobileNav() { className={`mobile-nav__item ${view === item.id ? "mobile-nav__item--active" : ""}`} onClick={() => setView(item.id)} aria-current={view === item.id ? "page" : undefined} + aria-label={item.label} > - {item.icon} + {item.label} @@ -36,7 +45,7 @@ export function MobileNav() { aria-label="Open Cortex Assist" > - ✦ + Assist diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 734d0c7..612990c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,12 +1,19 @@ import type { ViewId } from "../../types"; import { useApp } from "../../context/AppContext"; +import { + IconAgent, + IconGraph, + IconHome, + IconReview, + IconSearch, +} from "../ui/icons"; -const NAV: { id: ViewId; label: string; hint: string; icon: string }[] = [ - { id: "home", label: "Overview", hint: "Executive summary", icon: "⌂" }, - { id: "ask", label: "Search", hint: "Ask memory", icon: "?" }, - { id: "explore", label: "Memory map", hint: "Connections & lineage", icon: "◎" }, - { id: "agents", label: "AI agents", hint: "Inject & capture", icon: "⚡" }, - { id: "review", label: "Conflicts", hint: "Human review", icon: "⚖" }, +const NAV: { id: ViewId; label: string; hint: string; Icon: typeof IconHome }[] = [ + { id: "home", label: "Overview", hint: "Executive summary", Icon: IconHome }, + { id: "ask", label: "Search", hint: "Ask memory", Icon: IconSearch }, + { id: "explore", label: "Memory map", hint: "Connections & lineage", Icon: IconGraph }, + { id: "agents", label: "AI agents", hint: "Inject & capture", Icon: IconAgent }, + { id: "review", label: "Conflicts", hint: "Human review", Icon: IconReview }, ]; export function Sidebar() { @@ -26,9 +33,10 @@ export function Sidebar() { className={`sidebar__link ${view === item.id ? "sidebar__link--active" : ""}`} onClick={() => setView(item.id)} aria-current={view === item.id ? "page" : undefined} + aria-label={`${item.label} — ${item.hint}`} > - {item.icon} + {item.label} diff --git a/frontend/src/components/layout/WorkspaceBar.test.tsx b/frontend/src/components/layout/WorkspaceBar.test.tsx new file mode 100644 index 0000000..cd000da --- /dev/null +++ b/frontend/src/components/layout/WorkspaceBar.test.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkspaceBar } from "./WorkspaceBar"; +import { AppProvider } from "../../context/AppContext"; +import { ToastProvider } from "../ui/Toast"; + +function wrap(ui: ReactNode) { + return ( + + {ui} + + ); +} + +describe("WorkspaceBar", () => { + it("renders workspace controls and expandable connection settings", () => { + const view = render(wrap()); + const bar = view.getByLabelText(/workspace and api connection/i); + expect(within(bar).getByLabelText(/organization workspace/i)).toBeInTheDocument(); + fireEvent.click(within(bar).getByRole("button", { name: "Connection" })); + expect(within(bar).getByLabelText(/^api key$/i)).toBeInTheDocument(); + expect(within(bar).getByRole("button", { name: /test connection/i })).toBeInTheDocument(); + expect(within(bar).getByRole("button", { name: /replay tour/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/layout/WorkspaceBar.tsx b/frontend/src/components/layout/WorkspaceBar.tsx index eaacd06..98d8742 100644 --- a/frontend/src/components/layout/WorkspaceBar.tsx +++ b/frontend/src/components/layout/WorkspaceBar.tsx @@ -1,14 +1,14 @@ import { useId, useState } from "react"; import { useApp } from "../../context/AppContext"; import { fetchHealth, hasApiKeyConfigured } from "../../api/client"; -import { persistApiKey } from "../../lib/auth"; -import { setClientApiKey } from "../../api/client"; import { useToast } from "../ui/Toast"; +import { IconLock } from "../ui/icons"; const PRESETS = ["local-dev", "acme-demo"] as const; export function WorkspaceBar() { - const { workspaceId, setWorkspaceId, apiKey, setApiKey, saveApiKey } = useApp(); + const { workspaceId, setWorkspaceId, apiKey, setApiKey, saveApiKey, clearApiKey, replayOnboarding } = + useApp(); const { showToast } = useToast(); const [expanded, setExpanded] = useState(false); const [showKey, setShowKey] = useState(false); @@ -44,8 +44,7 @@ export function WorkspaceBar() { function handleClear(): void { setApiKey(""); - persistApiKey(""); - setClientApiKey(""); + clearApiKey(); setTestOk(null); showToast("API key cleared"); } @@ -89,7 +88,7 @@ export function WorkspaceBar() { className={`connection-bar__status ${secured ? "connection-bar__status--secured" : ""}`} aria-hidden > - {secured ? "🔒" : "◇"} + {secured ? : "◇"} Connection @@ -139,6 +138,9 @@ export function WorkspaceBar() { > {testing ? "Testing…" : "Test connection"} +
{secured ? (

diff --git a/frontend/src/components/memory/DecisionCard.test.tsx b/frontend/src/components/memory/DecisionCard.test.tsx new file mode 100644 index 0000000..780addc --- /dev/null +++ b/frontend/src/components/memory/DecisionCard.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DecisionCard } from "./DecisionCard"; +import type { DecisionResult } from "../../types"; +import { ToastProvider } from "../ui/Toast"; + +const decision: DecisionResult = { + event_id: "card-1", + event_type: "decision", + content: "Use Redis for session cache at checkout.", + made_by: ["alex@acme.example"], + affects: ["checkout-service"], + rationale: ["Sub-millisecond reads matter for cart state."], + importance_score: 0.75, + trust_score: 0.8, + extraction_confidence: 0.9, + source: "slack", + channel: "eng", + extracted_at: "2026-05-14T12:00:00Z", + status: "active", +}; + +describe("DecisionCard", () => { + it("expands to show rationale on header click", () => { + const view = render( + + + , + ); + fireEvent.click(view.getByRole("button", { name: /use redis/i })); + expect(view.getByText(/sub-millisecond reads/i)).toBeInTheDocument(); + }); + + it("shows copy link action when expanded", () => { + const view = render( + + + , + ); + expect(view.getAllByRole("button", { name: /copy link/i }).length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/components/memory/DecisionCard.tsx b/frontend/src/components/memory/DecisionCard.tsx index 97a798d..936d450 100644 --- a/frontend/src/components/memory/DecisionCard.tsx +++ b/frontend/src/components/memory/DecisionCard.tsx @@ -1,18 +1,38 @@ import { memo, useId, useState } from "react"; import type { DecisionResult } from "../../types"; import { formatRelativeTime, formatSource } from "../../lib/format"; +import { buildDecisionShareUrl } from "../../lib/routing"; +import { useToast } from "../ui/Toast"; import { DecisionScores } from "./DecisionScores"; +import { IconLink } from "../ui/icons"; type Props = { decision: DecisionResult; defaultOpen?: boolean; onSelect?: (id: string) => void; + onDetail?: (id: string) => void; selected?: boolean; }; -function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Props) { +function DecisionCardInner({ + decision: d, + defaultOpen, + onSelect, + onDetail, + selected, +}: Props) { const [open, setOpen] = useState(!!defaultOpen); const panelId = useId(); + const { showToast } = useToast(); + + async function copyLink(): Promise { + try { + await navigator.clipboard.writeText(buildDecisionShareUrl(d.event_id)); + showToast("Decision link copied"); + } catch { + showToast("Could not copy link"); + } + } return (

@@ -21,7 +41,10 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro className="decision-card__header" aria-expanded={open} aria-controls={panelId} - onClick={() => setOpen((v) => !v)} + onClick={() => { + setOpen((v) => !v); + onDetail?.(d.event_id); + }} > {d.event_type}

{d.content}

@@ -90,11 +113,10 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro ) : null}
- +
@@ -104,13 +126,12 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro ); } -// Lists of 10+ cards re-rendered on unrelated context updates; memo keeps the -// expensive markup stable when neither the decision nor the selection changes. export const DecisionCard = memo(DecisionCardInner, (prev, next) => { return ( prev.decision === next.decision && prev.selected === next.selected && prev.defaultOpen === next.defaultOpen && - prev.onSelect === next.onSelect + prev.onSelect === next.onSelect && + prev.onDetail === next.onDetail ); }); diff --git a/frontend/src/components/memory/DecisionDetailPanel.tsx b/frontend/src/components/memory/DecisionDetailPanel.tsx new file mode 100644 index 0000000..598e3f5 --- /dev/null +++ b/frontend/src/components/memory/DecisionDetailPanel.tsx @@ -0,0 +1,99 @@ +import type { DecisionResult } from "../../types"; +import { formatRelativeTime, formatSource, scorePercent } from "../../lib/format"; +import { DecisionScores } from "./DecisionScores"; +import { buildDecisionShareUrl } from "../../lib/routing"; +import { useToast } from "../ui/Toast"; +import { IconLink } from "../ui/icons"; + +type Props = { + decision: DecisionResult | null; + onClose: () => void; + onExplore?: (id: string) => void; +}; + +/** Side panel with full decision provenance and scores. */ +export function DecisionDetailPanel({ decision, onClose, onExplore }: Props) { + const { showToast } = useToast(); + + if (!decision) return null; + + async function copyLink(): Promise { + const url = buildDecisionShareUrl(decision!.event_id); + try { + await navigator.clipboard.writeText(url); + showToast("Decision link copied"); + } catch { + showToast("Could not copy link"); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/memory/LineageView.tsx b/frontend/src/components/memory/LineageView.tsx index 0ba3540..c5b5a6a 100644 --- a/frontend/src/components/memory/LineageView.tsx +++ b/frontend/src/components/memory/LineageView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { fetchCausalChain } from "../../api/client"; import type { DecisionResult } from "../../types"; import { formatRelativeTime } from "../../lib/format"; @@ -16,25 +16,24 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - let cancelled = false; + const load = useCallback(async () => { setLoading(true); setError(null); - fetchCausalChain(decisionId, workspaceId) - .then((res) => { - if (!cancelled) setNodes(res.nodes); - }) - .catch((e) => { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; + try { + const res = await fetchCausalChain(decisionId, workspaceId); + setNodes(res.nodes); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setNodes([]); + } finally { + setLoading(false); + } }, [decisionId, workspaceId]); + useEffect(() => { + void load(); + }, [load]); + if (loading) { return (
@@ -46,14 +45,22 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props } if (error) { return ( - + void load()}> + Retry + + } + > {error} ); } if (nodes.length === 0) { return ( - + This decision hasn't superseded or been triggered by anything else in the graph yet. ); diff --git a/frontend/src/components/memory/MemoryGraph.tsx b/frontend/src/components/memory/MemoryGraph.tsx index 68f8502..513804b 100644 --- a/frontend/src/components/memory/MemoryGraph.tsx +++ b/frontend/src/components/memory/MemoryGraph.tsx @@ -1,6 +1,8 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { DecisionResult } from "../../types"; import { truncate } from "../../lib/format"; +import { StateView } from "../ui/StateView"; +import { IconGraph } from "../ui/icons"; type GraphNode = { id: string; @@ -25,76 +27,94 @@ function graphDimensions(decisionCount: number): { w: number; h: number } { } export function MemoryGraph({ decisions, focusId, onFocus }: Props) { + const [hoverId, setHoverId] = useState(null); const { w, h } = graphDimensions(decisions.length); const { nodes, edges } = useMemo( () => buildGraph(decisions, focusId, w, h), [decisions, focusId, w, h], ); + const hoverNode = nodes.find((n) => n.id === hoverId); + if (decisions.length === 0) { return ( -
-

Search for decisions to see how people, systems, and choices connect.

-
+ } title="No graph data yet"> + Search for decisions to see how people, systems, and choices connect. + ); } return (
- - - - - - - - {edges.map((e) => { - const a = nodes.find((n) => n.id === e.from); - const b = nodes.find((n) => n.id === e.to); - if (!a || !b) return null; - return ( - - ); - })} - {nodes.map((n) => ( - onFocus?.(n.id)} - style={{ cursor: onFocus ? "pointer" : "default" }} - role="button" - tabIndex={0} - aria-label={`${n.kind}: ${n.label}`} - aria-pressed={focusId === n.id} - onKeyDown={(ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - onFocus?.(n.id); - } - }} - > - - - {truncate(n.label, n.kind === "decision" ? 28 : 14)} - - - ))} - + {hoverNode ? ( +
+ {hoverNode.kind} · {hoverNode.label} +
+ ) : null} +
+ + + + + + + + {edges.map((e) => { + const a = nodes.find((n) => n.id === e.from); + const b = nodes.find((n) => n.id === e.to); + if (!a || !b) return null; + return ( + + ); + })} + {nodes.map((n) => ( + onFocus?.(n.id)} + onMouseEnter={() => setHoverId(n.id)} + onMouseLeave={() => setHoverId(null)} + onFocus={() => setHoverId(n.id)} + onBlur={() => setHoverId(null)} + style={{ cursor: onFocus ? "pointer" : "default" }} + role="button" + tabIndex={0} + aria-label={`${n.kind}: ${n.label}`} + aria-pressed={focusId === n.id} + onKeyDown={(ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + onFocus?.(n.id); + } + }} + > + + + {truncate(n.label, n.kind === "decision" ? 28 : 14)} + + + ))} + +
Decision diff --git a/frontend/src/components/memory/TimelineView.tsx b/frontend/src/components/memory/TimelineView.tsx index d6b4fa6..2d51bb9 100644 --- a/frontend/src/components/memory/TimelineView.tsx +++ b/frontend/src/components/memory/TimelineView.tsx @@ -1,5 +1,7 @@ import type { DecisionResult } from "../../types"; import { formatRelativeTime, formatSource } from "../../lib/format"; +import { StateView } from "../ui/StateView"; +import { IconEmpty } from "../ui/icons"; type Props = { decisions: DecisionResult[]; @@ -13,9 +15,9 @@ export function TimelineView({ decisions, onSelect }: Props) { if (sorted.length === 0) { return ( -
-

Your organizational timeline will appear here after you search or load memories.

-
+ } title="Timeline is empty"> + Your organizational timeline will appear here after you search or load memories. + ); } diff --git a/frontend/src/components/onboarding/OnboardingModal.tsx b/frontend/src/components/onboarding/OnboardingModal.tsx index f4a0ac4..4b432d8 100644 --- a/frontend/src/components/onboarding/OnboardingModal.tsx +++ b/frontend/src/components/onboarding/OnboardingModal.tsx @@ -1,69 +1,174 @@ import { useState } from "react"; import { markOnboardingComplete } from "../../lib/onboarding"; +import { useApp } from "../../context/AppContext"; +import { IconSpark } from "../ui/icons"; type Props = { onComplete: () => void; onOpenCopilot: () => void; + onFinishAsk: (workspace: string, query: string) => void; }; -const STEPS = [ - { - title: "Your organization's living memory", - body: "Cortex captures decisions — not documents — from Slack, GitHub, Jira, and more. Ask natural questions and get answers with who decided, why, and what's affected.", - icon: "◈", - }, - { - title: "Ask in plain language", - body: 'Try questions like "Why did we choose CockroachDB for payments?" or "What affects checkout?" Cortex searches structured memory, not scattered threads.', - icon: "?", - }, - { - title: "Meet Assist", - body: "Cortex Assist guides you, runs searches, and explains results. Use the memory map to see how people, systems, and decisions connect over time.", - icon: "✦", - }, +const WORKSPACE_PRESETS = [ + { id: "local-dev", label: "Demo workspace", hint: "Pre-seeded decisions for demos" }, + { id: "oss-tiangolo-fastapi", label: "OSS: FastAPI", hint: "Open-source graph import" }, + { id: "oss-adr", label: "OSS: ADRs", hint: "Architecture decision records" }, ] as const; -export function OnboardingModal({ onComplete, onOpenCopilot }: Props) { +const DEMO_QUERY = "Why CockroachDB for payments?"; + +export function OnboardingModal({ onComplete, onOpenCopilot, onFinishAsk }: Props) { + const { setWorkspaceId, apiKey, setApiKey, saveApiKey } = useApp(); const [step, setStep] = useState(0); - const current = STEPS[step]; - const isLast = step === STEPS.length - 1; + const [workspace, setWorkspace] = useState("local-dev"); + const isLast = step === 3; - function finish() { - markOnboardingComplete(); + function finishToAsk(): void { + markOnboardingComplete(workspace); + setWorkspaceId(workspace); onComplete(); - if (isLast) onOpenCopilot(); + onFinishAsk(workspace, DEMO_QUERY); + } + + function finishToAssist(): void { + markOnboardingComplete(workspace); + setWorkspaceId(workspace); + onComplete(); + onOpenCopilot(); } return ( -
+
- {STEPS.map((_, i) => ( + {[0, 1, 2, 3].map((i) => ( ))}
- - {current.icon} - -

- {current.title} -

-

{current.body}

+ + {step === 0 ? ( + <> + + ◈ + +

+ Your organization's living memory +

+

+ Cortex captures decisions — not documents — from Slack, GitHub, Jira, + and more. Ask natural questions and get answers with who decided, why, and what's + affected. +

+ + ) : null} + + {step === 1 ? ( + <> +

+ Choose a workspace +

+

+ Workspaces isolate organizational memory. Start with the demo workspace or pick an OSS + preset. +

+
+ {WORKSPACE_PRESETS.map((w) => ( + + ))} +
+ + ) : null} + + {step === 2 ? ( + <> +

+ Try a demo search +

+

+ We'll open Search with a sample question so you can see trust scores, coverage, and + decision stories in seconds. +

+
{DEMO_QUERY}
+ + ) : null} + + {step === 3 ? ( + <> +

+ Connection (optional) +

+

+ The public demo works without a key. If your deployment requires authentication, add + your API key now — you can always change it under Connection later. +

+ + setApiKey(e.target.value)} + placeholder="Optional" + autoComplete="off" + /> + + ) : null} +
{step > 0 ? ( ) : ( - )} {isLast ? ( - + <> + + + ) : ( - {results.length === 0 ? ( - - Try a broader query, or run make demo to seed example decisions - for local-dev. - - ) : ( -
{resultList}
- )} + +
+ {SOURCE_FILTERS.map((src) => ( + + ))} + +
+ +
+
+ {filteredResults.length === 0 ? ( + } title="No memories matched filters"> + Broaden your query or lower the trust threshold. If this workspace is new, capture + decisions from your tools or the AI agents view. + + ) : ( + filteredResults.map((d, i) => ( + + )) + )} +
+ setDetailDecisionId(null)} + onExplore={(id) => { + setSelectedDecisionId(id); + setView("explore", { decision: id }); + }} + /> +
) : null}
diff --git a/frontend/src/views/ExploreView.tsx b/frontend/src/views/ExploreView.tsx index bf58e4d..fa92025 100644 --- a/frontend/src/views/ExploreView.tsx +++ b/frontend/src/views/ExploreView.tsx @@ -1,13 +1,17 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useApp } from "../context/AppContext"; +import { fetchContradictions } from "../api/client"; import { MemoryGraph } from "../components/memory/MemoryGraph"; import { TimelineView } from "../components/memory/TimelineView"; import { LineageView } from "../components/memory/LineageView"; import { DecisionCard } from "../components/memory/DecisionCard"; +import { DecisionDetailPanel } from "../components/memory/DecisionDetailPanel"; import { WorkspaceBar } from "../components/layout/WorkspaceBar"; import { PageHeader } from "../components/ui/PageHeader"; import { StateView } from "../components/ui/StateView"; +import { IconGraph } from "../components/ui/icons"; import { resolveDecisionFocus } from "../lib/decision"; +import { parseHash } from "../lib/routing"; type ExploreTab = "graph" | "timeline" | "lineage"; @@ -19,30 +23,63 @@ export function ExploreView() { lastQuery, workspaceId, setView, + detailDecisionId, + setDetailDecisionId, } = useApp(); const [tab, setTab] = useState("graph"); + const [conflictCount, setConflictCount] = useState(0); const focusId = selectedDecisionId ?? exploreDecisions[0]?.event_id ?? null; const decisions = exploreDecisions.length > 0 ? exploreDecisions : lastQuery?.results ?? []; + const focusOnly = decisions.length === 0 && Boolean(selectedDecisionId); + + useEffect(() => { + const route = parseHash(); + if (route.decisionId) { + setSelectedDecisionId(route.decisionId); + setDetailDecisionId(route.decisionId); + } + }, [setSelectedDecisionId, setDetailDecisionId]); + + useEffect(() => { + if (focusOnly) setTab("lineage"); + }, [focusOnly, selectedDecisionId]); + + useEffect(() => { + const ws = workspaceId.trim() || "local-dev"; + void fetchContradictions(ws) + .then((items) => setConflictCount(items.length)) + .catch(() => setConflictCount(0)); + }, [workspaceId]); const handleCardSelect = useCallback( (id: string) => { - setSelectedDecisionId(resolveDecisionFocus(id, decisions, focusId)); + const next = resolveDecisionFocus(id, decisions, focusId); + if (next) { + setSelectedDecisionId(next); + setDetailDecisionId(next); + } if (id.startsWith("person:") || id.startsWith("system:")) { setTab("graph"); } }, - [decisions, focusId, setSelectedDecisionId], + [decisions, focusId, setSelectedDecisionId, setDetailDecisionId], ); const handleTimelineSelect = useCallback( (decisionId: string) => { setSelectedDecisionId(decisionId); + setDetailDecisionId(decisionId); setTab("graph"); }, - [setSelectedDecisionId], + [setSelectedDecisionId, setDetailDecisionId], ); + const detailDecision = + decisions.find((d) => d.event_id === detailDecisionId) ?? + (focusId ? decisions.find((d) => d.event_id === focusId) : null) ?? + null; + return (
0 ? (

- Showing {decisions.length} result{decisions.length === 1 ? "" : "s"} for{" "} - {lastQuery.query} + Showing {decisions.length} decision{decisions.length === 1 ? "" : "s"} + {conflictCount > 0 ? ( + <> + {" "} + · {conflictCount} open conflict{conflictCount === 1 ? "" : "s"} + + ) : null}{" "} + for {lastQuery.query} {lastQuery.latency_ms ? <> · {lastQuery.latency_ms}ms : null}

) : null} - {decisions.length === 0 ? ( + {decisions.length === 0 && !selectedDecisionId ? ( } title="No memories to map yet" action={ } > @@ -89,56 +132,84 @@ export function ExploreView() { onClick={() => setTab(t)} > {t === "graph" ? "Relationships" : t === "timeline" ? "Timeline" : "Lineage"} + {t === "graph" && decisions.length > 0 ? ( + {decisions.length} + ) : null} + {t === "timeline" && conflictCount > 0 ? ( + {conflictCount} + ) : null} ))} -
- {tab === "graph" ? ( - { - const next = resolveDecisionFocus(id, decisions, focusId); - if (next) setSelectedDecisionId(next); - }} - /> - ) : null} - {tab === "timeline" ? ( - - ) : null} - {tab === "lineage" && focusId ? ( - - ) : null} - {tab === "lineage" && !focusId ? ( - - Pick a decision below to trace supersession and trigger lineage. - - ) : null} -
- -
-

Decisions in this view

-
- {decisions.map((d) => ( - +
+ {tab === "graph" ? ( + { + const next = resolveDecisionFocus(id, decisions, focusId); + if (next) { + setSelectedDecisionId(next); + setDetailDecisionId(next); + } + }} /> - ))} -
-
+ ) : null} + {tab === "timeline" ? ( + + ) : null} + {tab === "lineage" && focusId ? ( + { + setSelectedDecisionId(id); + setDetailDecisionId(id); + }} + /> + ) : null} + {tab === "lineage" && !focusId ? ( + + Pick a decision below to trace supersession and trigger lineage. + + ) : null} + + + setDetailDecisionId(null)} + onExplore={(id) => setSelectedDecisionId(id)} + /> + + + {decisions.length > 0 ? ( +
+

Decisions in this view

+
+ {decisions.map((d) => ( + + ))} +
+
+ ) : focusOnly && focusId ? ( +

+ Tracing lineage for decision {focusId.slice(0, 8)}… — run{" "} + Search to populate the relationship graph. +

+ ) : null} )}
diff --git a/frontend/src/views/HomeView.tsx b/frontend/src/views/HomeView.tsx index b154e32..87e3343 100644 --- a/frontend/src/views/HomeView.tsx +++ b/frontend/src/views/HomeView.tsx @@ -4,12 +4,15 @@ import type { DecisionResult, Health } from "../types"; import { useApp } from "../context/AppContext"; import { buildDashboardMetrics, trustHeadline } from "../lib/insights"; import { scorePercent } from "../lib/format"; +import { isLiveDemoHost } from "../lib/settings"; import { PageHeader } from "../components/ui/PageHeader"; import { MetricCard } from "../components/ui/MetricCard"; import { DecisionTeaser } from "../components/dashboard/DecisionTeaser"; import { MemoryGraph } from "../components/memory/MemoryGraph"; -import { Skeleton } from "../components/ui/Skeleton"; +import { WorkspaceBar } from "../components/layout/WorkspaceBar"; +import { SkeletonStack } from "../components/ui/Skeleton"; import { StateView } from "../components/ui/StateView"; +import { IconEmpty } from "../components/ui/icons"; export function HomeView() { const { @@ -19,11 +22,13 @@ export function HomeView() { setLastQuery, workspaceId, setAssistantOpen, + lastQuery, } = useApp(); const [decisions, setDecisions] = useState([]); const [health, setHealth] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [metrics, setMetrics] = useState(() => buildDashboardMetrics([], [])); const load = useCallback(async () => { setLoading(true); @@ -31,7 +36,11 @@ export function HomeView() { const ws = workspaceId.trim() || "local-dev"; try { const [queryRes, contradictions, healthRes] = await Promise.all([ - queryMemory({ query: "organizational decisions architecture payments", workspace_id: ws, limit: 12 }), + queryMemory({ + query: "organizational decisions architecture payments", + workspace_id: ws, + limit: 12, + }), fetchContradictions(ws).catch(() => []), fetchHealth().catch(() => null), ]); @@ -50,17 +59,35 @@ export function HomeView() { } }, [workspaceId, setLastQuery, setExploreDecisions, setSelectedDecisionId]); - const [metrics, setMetrics] = useState(() => - buildDashboardMetrics([], []), - ); - useEffect(() => { void load(); }, [load]); function openDecision(id: string) { setSelectedDecisionId(id); - setView("explore"); + setView("explore", { decision: id }); + } + + const coverage = + typeof lastQuery?.coverage_score === "number" + ? lastQuery.coverage_score + : metrics.decisionCount > 0 + ? 0.45 + : 0; + + if (loading && !decisions.length && !error) { + return ( +
+ + + + +
+ ); } return ( @@ -90,67 +117,68 @@ export function HomeView() { } /> + {isLiveDemoHost() ? ( +

+ Live demo — connected to Cortex cloud memory. Try Search or open + Connection to point at your own API. +

+ ) : null} + + + {error ? ( - void load()}> - Retry - - }> + void load()}> + Retry + + } + > {error} ) : null} - {loading ? ( -
- {Array.from({ length: 4 }, (_, i) => ( - - ))} -
- ) : ( -
- - - - -
- )} +
+ + + + +

Recent decisions

- {loading ? ( - <> - - - - ) : metrics.recentDecisions.length === 0 ? ( + {metrics.recentDecisions.length === 0 ? ( } title="No decisions yet" action={ } > - Run make demo to seed example memories, or capture a decision from the AI agents view. + Connect your tools or capture a decision from AI agents — then search to see + organizational memory here. ) : (
@@ -168,7 +197,7 @@ export function HomeView() {
)} {metrics.recentDecisions.length > 0 ? ( -