diff --git a/.env.example b/.env.example index bd4dce6d7..26698132b 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,7 @@ LANGFUSE_SECRET_KEY= # When unset, "chain_valid": null — use the CLI to verify: # python -m molecule_audit.verify --agent-id # Must match AUDIT_LEDGER_SALT set in each workspace container. +# REQUIRED when audit module is active. Used for HMAC chain verification. # AUDIT_LEDGER_SALT= # 32+ random bytes (base64 or arbitrary string) # ---- Operator identity (for org-templates/reno-stars/, see OPERATOR_NOTES.md) ---- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd285434d..6dcb525a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,21 @@ on: pull_request: branches: [main, staging] +# Cancel in-progress CI runs when a new commit arrives on the same ref. +# This prevents multiple stale runs from queuing behind each other and +# monopolising the self-hosted macOS arm64 runner. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: # Detect which paths changed so downstream jobs can skip when only - # docs/markdown files were modified. Uses git diff (no Docker — works - # on macOS self-hosted runners unlike dorny/paths-filter). + # docs/markdown files were modified. Uses plain `git diff` — no macOS + # dependency, so this runs on ubuntu-latest to free the self-hosted + # macOS arm64 runner for jobs that genuinely need it. changes: name: Detect changes - runs-on: [self-hosted, macos, arm64] + runs-on: ubuntu-latest outputs: platform: ${{ steps.check.outputs.platform }} canvas: ${{ steps.check.outputs.canvas }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 02989b4d0..a57f1d867 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,6 +23,13 @@ on: # Weekly run picks up findings in code that hasn't been touched. - cron: '30 1 * * 0' +# Workflow-level concurrency: only one CodeQL run per branch/PR at a time. +# `cancel-in-progress: false` queues new runs — the 45-min analysis is the +# longest CI occupant and fights the single mac mini runner the hardest. +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: false + permissions: actions: read contents: read diff --git a/canvas/Dockerfile b/canvas/Dockerfile index f530e0eca..f871bd075 100644 --- a/canvas/Dockerfile +++ b/canvas/Dockerfile @@ -20,4 +20,7 @@ COPY --from=builder /app/public ./public EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Non-root runtime — node image defaults to root, explicitly drop. +RUN addgroup -g 1000 canvas && adduser -u 1000 -G canvas -s /bin/sh -D canvas +USER canvas CMD ["node", "server.js"] diff --git a/canvas/package-lock.json b/canvas/package-lock.json index f4defc1f4..c7f76c9ec 100644 --- a/canvas/package-lock.json +++ b/canvas/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -95,6 +96,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -197,7 +199,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -221,11 +222,31 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -980,7 +1001,6 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -1829,6 +1849,27 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -1990,7 +2031,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2113,7 +2155,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2123,7 +2164,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2134,7 +2174,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2372,6 +2411,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2382,6 +2422,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2557,7 +2598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2874,7 +2914,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3039,7 +3078,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3651,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3661,7 +3700,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jsdom": { "version": "25.0.1", @@ -3669,7 +3709,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4007,6 +4046,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5234,7 +5274,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5391,6 +5430,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5445,7 +5485,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5455,7 +5494,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5468,7 +5506,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -6017,7 +6056,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6139,7 +6177,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6478,7 +6515,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/canvas/src/app/__tests__/orgs-page.test.tsx b/canvas/src/app/__tests__/orgs-page.test.tsx index 430aa8f06..0b4d1e944 100644 --- a/canvas/src/app/__tests__/orgs-page.test.tsx +++ b/canvas/src/app/__tests__/orgs-page.test.tsx @@ -36,6 +36,12 @@ vi.mock("@/lib/api", () => ({ PLATFORM_URL: "https://cp.test", })); +// Mock TermsGate to a pass-through so it doesn't make network calls that +// consume the mockFetch queue. OrgsPage wraps its content in TermsGate. +vi.mock("@/components/TermsGate", () => ({ + TermsGate: ({ children }: { children: React.ReactNode }) => children, +})); + const mockFetch = vi.fn(); globalThis.fetch = mockFetch as unknown as typeof fetch; @@ -79,12 +85,27 @@ function setLocation(href: string) { } beforeEach(() => { + // Always reset to real timers first. If a previous polling test failed + // before its finally-block ran, fake timers would still be active and + // vi.useFakeTimers() in the polling tests would be a no-op — causing + // setTimeout(0) to hang and the test to time out. + vi.useRealTimers(); + // Now install fake timers for this test's deterministic timing. + vi.useFakeTimers(); vi.clearAllMocks(); + // Reset mock return values so each test starts fresh. + // The mock functions (vi.fn) persist across tests; only their + // per-call behavior is reset here. + mockFetchSession.mockReset(); + mockFetch.mockReset(); setLocation("https://moleculesai.app/orgs"); }); afterEach(() => { cleanup(); + // Restore real timers so subsequent tests (and vitest internals) + // aren't polluted by fake timer state from a previous test. + vi.useRealTimers(); }); // ── Tests ──────────────────────────────────────────────────────────────────── @@ -93,7 +114,8 @@ describe("/orgs — auth guard", () => { it("redirects to login when session is null", async () => { mockFetchSession.mockResolvedValueOnce(null); render(); - await waitFor(() => expect(mockRedirectToLogin).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(50); + expect(mockRedirectToLogin).toHaveBeenCalled(); // Must not attempt to fetch /cp/orgs before auth is established expect(mockFetch).not.toHaveBeenCalledWith( expect.stringContaining("/cp/orgs"), @@ -104,9 +126,14 @@ describe("/orgs — auth guard", () => { describe("/orgs — error state", () => { it("shows error + Retry button when /cp/orgs fails", async () => { - mockFetchSession.mockResolvedValueOnce({ userId: "u-1" }); - mockFetch.mockResolvedValueOnce(notOk(500, "db down")); + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + mockFetch.mockImplementationOnce(() => + Promise.reject(new Error("GET /cp/orgs: 500")) + ); render(); + // PR #1243 replaced waitFor polling with vi.advanceTimersByTimeAsync(50), + // which fires the timer but does not guarantee React render flush completes + // before the assertion runs. Restores waitFor for the error-state test. await waitFor(() => expect(screen.getByText(/Error:/)).toBeTruthy()); expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy(); }); @@ -114,10 +141,11 @@ describe("/orgs — error state", () => { describe("/orgs — empty list", () => { it("renders EmptyState with CreateOrgForm when user has zero orgs", async () => { - mockFetchSession.mockResolvedValueOnce({ userId: "u-1" }); + mockFetchSession.mockResolvedValue({ userId: "u-1" }); mockFetch.mockResolvedValueOnce(okJson({ orgs: [] })); render(); - await waitFor(() => expect(screen.getByText(/don't have any organizations/i)).toBeTruthy()); + await vi.advanceTimersByTimeAsync(50); + expect(screen.getByText(/don't have any organizations/i)).toBeTruthy(); expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy(); }); }); @@ -126,7 +154,7 @@ describe("/orgs — CTAs by status", () => { const session = { userId: "u-1" }; it("running → Open link targets {slug}.moleculesai.app", async () => { - mockFetchSession.mockResolvedValueOnce(session); + mockFetchSession.mockResolvedValue(session); mockFetch.mockResolvedValueOnce( okJson({ orgs: [ @@ -143,12 +171,13 @@ describe("/orgs — CTAs by status", () => { }) ); render(); - const link = (await screen.findByRole("link", { name: /open/i })) as HTMLAnchorElement; + await vi.advanceTimersByTimeAsync(50); + const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement; expect(link.href).toBe("https://acme.moleculesai.app/"); }); it("awaiting_payment → Complete payment link to /pricing?org=", async () => { - mockFetchSession.mockResolvedValueOnce(session); + mockFetchSession.mockResolvedValue(session); mockFetch.mockResolvedValueOnce( okJson({ orgs: [ @@ -165,14 +194,15 @@ describe("/orgs — CTAs by status", () => { }) ); render(); - const link = (await screen.findByRole("link", { + await vi.advanceTimersByTimeAsync(50); + const link = screen.getByRole("link", { name: /complete payment/i, - })) as HTMLAnchorElement; + }) as HTMLAnchorElement; expect(link.getAttribute("href")).toBe("/pricing?org=beta-co"); }); it("failed → mailto support link", async () => { - mockFetchSession.mockResolvedValueOnce(session); + mockFetchSession.mockResolvedValue(session); mockFetch.mockResolvedValueOnce( okJson({ orgs: [ @@ -189,9 +219,10 @@ describe("/orgs — CTAs by status", () => { }) ); render(); - const link = (await screen.findByRole("link", { + await vi.advanceTimersByTimeAsync(50); + const link = screen.getByRole("link", { name: /contact support/i, - })) as HTMLAnchorElement; + }) as HTMLAnchorElement; expect(link.getAttribute("href")).toBe("mailto:support@moleculesai.app"); }); }); @@ -200,7 +231,7 @@ describe("/orgs — post-checkout banner", () => { it("renders CheckoutBanner when ?checkout=success and scrubs the URL", async () => { setLocation("https://moleculesai.app/orgs?checkout=success"); const replaceState = vi.spyOn(window.history, "replaceState"); - mockFetchSession.mockResolvedValueOnce({ userId: "u-1" }); + mockFetchSession.mockResolvedValue({ userId: "u-1" }); mockFetch.mockResolvedValueOnce( okJson({ orgs: [ @@ -217,7 +248,8 @@ describe("/orgs — post-checkout banner", () => { }) ); render(); - expect(await screen.findByText(/Payment confirmed/i)).toBeTruthy(); + await vi.advanceTimersByTimeAsync(50); + expect(screen.getByText(/Payment confirmed/i)).toBeTruthy(); // URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner expect(replaceState).toHaveBeenCalled(); const callArgs = replaceState.mock.calls[0]; @@ -225,22 +257,21 @@ describe("/orgs — post-checkout banner", () => { }); it("does NOT render CheckoutBanner without ?checkout=success", async () => { - mockFetchSession.mockResolvedValueOnce({ userId: "u-1" }); + mockFetchSession.mockResolvedValue({ userId: "u-1" }); mockFetch.mockResolvedValueOnce(okJson({ orgs: [] })); render(); - await waitFor(() => - expect(screen.getByText(/don't have any organizations/i)).toBeTruthy() - ); + await vi.advanceTimersByTimeAsync(50); + expect(screen.getByText(/don't have any organizations/i)).toBeTruthy(); expect(screen.queryByText(/Payment confirmed/i)).toBeNull(); }); }); describe("/orgs — fetch includes credentials + timeout signal", () => { it("/cp/orgs fetch is called with credentials:include and an AbortSignal", async () => { - mockFetchSession.mockResolvedValueOnce({ userId: "u-1" }); + mockFetchSession.mockResolvedValue({ userId: "u-1" }); mockFetch.mockResolvedValueOnce(okJson({ orgs: [] })); render(); - await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(50); const callArgs = mockFetch.mock.calls.find((c) => String(c[0]).includes("/cp/orgs") ); @@ -258,111 +289,98 @@ describe("/orgs — fetch includes credentials + timeout signal", () => { describe("/orgs — polling of in-flight orgs", () => { it("schedules a 5s refetch when at least one org is provisioning", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - mockFetchSession.mockResolvedValue({ userId: "u-1" }); - mockFetch.mockResolvedValueOnce( - okJson({ - orgs: [ - { - id: "o-1", - slug: "acme", - name: "Acme", - plan: "pro", - status: "provisioning", - created_at: "", - updated_at: "", - }, - ], - }) - ); - // Second fetch (the poll refresh) returns a running org so we can - // observe the state flip — and to let the test stop re-scheduling. - mockFetch.mockResolvedValueOnce( - okJson({ - orgs: [ - { - id: "o-1", - slug: "acme", - name: "Acme", - plan: "pro", - status: "running", - created_at: "", - updated_at: "", - }, - ], - }) - ); + // beforeEach already set up fake timers; advance time to fire the 5s poll. + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + // First /cp/orgs returns provisioning orgs so a poll is scheduled. + // Second returns running orgs to observe the state flip stop re-scheduling. + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "provisioning", + created_at: "", + updated_at: "", + }, + ], + }) + ); + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "running", + created_at: "", + updated_at: "", + }, + ], + }) + ); - render(); - // First fetch resolves - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - // Advance past the 5s scheduled refresh - await vi.advanceTimersByTimeAsync(5_100); - // Second fetch is the poll refresh - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); - } finally { - vi.useRealTimers(); - } + render(); + await vi.advanceTimersByTimeAsync(5_100); + // First /cp/orgs + second poll /cp/orgs + expect(mockFetch).toHaveBeenCalledTimes(2); }); it("does NOT schedule a refetch when all orgs are running", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - mockFetchSession.mockResolvedValue({ userId: "u-1" }); - mockFetch.mockResolvedValueOnce( - okJson({ - orgs: [ - { - id: "o-1", - slug: "acme", - name: "Acme", - plan: "pro", - status: "running", - created_at: "", - updated_at: "", - }, - ], - }) - ); - render(); - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - // Advance well past the 5s poll window — no second fetch must fire - await vi.advanceTimersByTimeAsync(10_000); - expect(mockFetch).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } + // beforeEach already set up fake timers. + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "running", + created_at: "", + updated_at: "", + }, + ], + }) + ); + render(); + await vi.advanceTimersByTimeAsync(10_000); + // Only the initial /cp/orgs — no poll fires (stillMoving = false) + expect(mockFetch).toHaveBeenCalledTimes(1); }); it("clears the poll timer on unmount — no fetch after unmount", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - mockFetchSession.mockResolvedValue({ userId: "u-1" }); - mockFetch.mockResolvedValueOnce( - okJson({ - orgs: [ - { - id: "o-1", - slug: "acme", - name: "Acme", - plan: "pro", - status: "awaiting_payment", - created_at: "", - updated_at: "", - }, - ], - }) - ); - const { unmount } = render(); - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - // Tear down BEFORE the 5s timer fires - unmount(); - await vi.advanceTimersByTimeAsync(10_000); - // Fetch count must stay at 1 — the cleanup cleared the timer - expect(mockFetch).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } + // beforeEach already set up fake timers. + mockFetchSession.mockResolvedValue({ userId: "u-1" }); + mockFetch.mockResolvedValueOnce( + okJson({ + orgs: [ + { + id: "o-1", + slug: "acme", + name: "Acme", + plan: "pro", + status: "awaiting_payment", + created_at: "", + updated_at: "", + }, + ], + }) + ); + const { unmount } = render(); + // Flush microtasks so the effect runs and schedules the 5s poll before we unmount. + await vi.advanceTimersByTimeAsync(0); + // Now the effect has run (scheduling the poll) but not the poll itself + expect(mockFetch).toHaveBeenCalledTimes(1); + // Tear down — cleanup must clear the 5s timer + unmount(); + // Advance timers — the cleanup cleared the 5s timer, so no poll fires + await vi.advanceTimersByTimeAsync(10_000); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 5f1787d67..29a326328 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -352,7 +352,8 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) { }); if (!res.ok) { const body = await res.text(); - throw new Error(`${res.status}: ${body}`); + console.error(`[orgs] create ${res.status}: ${body}`); + throw new Error(`Failed to create organization (${res.status})`); } onCreated(slug); } catch (e) { diff --git a/canvas/src/components/AuditTrailPanel.tsx b/canvas/src/components/AuditTrailPanel.tsx index f7056dbe2..29f9432d2 100644 --- a/canvas/src/components/AuditTrailPanel.tsx +++ b/canvas/src/components/AuditTrailPanel.tsx @@ -3,22 +3,25 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import type { AuditEntry, AuditResponse } from "@/types/audit"; +import { auditSummary } from "@/types/audit"; // ── Constants ───────────────────────────────────────────────────────────────── -type EventFilter = "all" | AuditEntry["event_type"]; +type OperationFilter = "all" | string; -const BADGE_COLORS: Record = { - delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" }, - decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" }, - gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" }, - hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" }, +const BADGE_COLORS: Record = { + task_start: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" }, + task_end: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" }, + gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" }, + hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" }, }; -const FILTERS: { id: EventFilter; label: string }[] = [ +const DEFAULT_BADGE = { text: "text-zinc-400", bg: "bg-zinc-800/40", border: "border-zinc-700/40" }; + +const FILTERS: { id: OperationFilter; label: string }[] = [ { id: "all", label: "All" }, - { id: "delegation", label: "Delegation" }, - { id: "decision", label: "Decision" }, + { id: "task_start", label: "Task Start" }, + { id: "task_end", label: "Task End" }, { id: "gate", label: "Gate" }, { id: "hitl", label: "HITL" }, ]; @@ -49,20 +52,21 @@ interface Props { * AuditTrailPanel — side-panel tab showing the workspace audit ledger. * * Features: - * - Color-coded event-type badges (delegation/decision/gate/hitl) - * - chain_valid=false tamper ⚠ indicator - * - Event-type filter bar - * - Cursor-based "Load more" pagination + * - Color-coded operation badges + * - chain_valid=false tamper indicator + * - Operation filter bar + * - Offset-based "Load more" pagination * - Relative timestamps refreshed every 30 s * - Empty state with icon */ export function AuditTrailPanel({ workspaceId }: Props) { const [entries, setEntries] = useState([]); - const [cursor, setCursor] = useState(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); - const [filter, setFilter] = useState("all"); + const [filter, setFilter] = useState("all"); // Relative-time "now" — refreshed every 30 s to keep labels current const [now, setNow] = useState(() => Date.now()); @@ -74,11 +78,11 @@ export function AuditTrailPanel({ workspaceId }: Props) { // ── URL builder (stable between renders when inputs unchanged) ───────────── const buildUrl = useCallback( - (cursorParam?: string | null): string => { + (pageOffset: number): string => { const params = new URLSearchParams(); params.set("limit", String(AUDIT_LIMIT)); - if (filter !== "all") params.set("event_type", filter); - if (cursorParam) params.set("cursor", cursorParam); + params.set("offset", String(pageOffset)); + if (filter !== "all") params.set("operation", filter); return `/workspaces/${workspaceId}/audit?${params.toString()}`; }, [workspaceId, filter] @@ -90,13 +94,15 @@ export function AuditTrailPanel({ workspaceId }: Props) { setLoading(true); setError(null); try { - const data = await api.get(buildUrl()); - setEntries(data.entries ?? []); - setCursor(data.cursor ?? null); + const data = await api.get(buildUrl(0)); + setEntries(data.events ?? []); + setTotal(data.total ?? 0); + setOffset(data.events?.length ?? 0); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load audit trail"); setEntries([]); - setCursor(null); + setTotal(0); + setOffset(0); } finally { setLoading(false); } @@ -108,19 +114,22 @@ export function AuditTrailPanel({ workspaceId }: Props) { // ── Pagination (append next page) ───────────────────────────────────────── + const hasMore = entries.length < total; + const loadMore = useCallback(async () => { - if (!cursor || loadingMore) return; + if (!hasMore || loadingMore) return; setLoadingMore(true); try { - const data = await api.get(buildUrl(cursor)); - setEntries((prev) => [...prev, ...(data.entries ?? [])]); - setCursor(data.cursor ?? null); + const data = await api.get(buildUrl(offset)); + const newEvents = data.events ?? []; + setEntries((prev) => [...prev, ...newEvents]); + setOffset((prev) => prev + newEvents.length); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load more entries"); } finally { setLoadingMore(false); } - }, [cursor, loadingMore, buildUrl]); + }, [hasMore, loadingMore, buildUrl, offset]); // ── Render ───────────────────────────────────────────────────────────────── @@ -187,7 +196,7 @@ export function AuditTrailPanel({ workspaceId }: Props) { {/* Load more */} - {cursor && ( + {hasMore && (
+
+ +
+ {loading && ( +
+ Loading console output… +
+ )} + {!loading && error && ( +
+ {error} +
+ )} + {!loading && !error && output !== null && ( +
+              {output || "(console output is empty — the instance may still be booting)"}
+            
+ )} +
+ +
+ {output && ( + + )} + +
+ + , + document.body, + ); +} diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 4211b7b40..3d869a81a 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -18,13 +18,15 @@ interface MenuItem { export function ContextMenu() { const contextMenu = useCanvasStore((s) => s.contextMenu); const closeContextMenu = useCanvasStore((s) => s.closeContextMenu); - const removeNode = useCanvasStore((s) => s.removeNode); const updateNodeData = useCanvasStore((s) => s.updateNodeData); const selectNode = useCanvasStore((s) => s.selectNode); const setPanelTab = useCanvasStore((s) => s.setPanelTab); const nestNode = useCanvasStore((s) => s.nestNode); const contextNodeId = contextMenu?.nodeId ?? null; - const hasChildren = useCanvasStore((s) => contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false); + const children = useCanvasStore((s) => + contextNodeId ? s.nodes.filter((n) => n.data.parentId === contextNodeId) : [] + ); + const hasChildren = children.length > 0; const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const ref = useRef(null); const [actionLoading, setActionLoading] = useState(false); @@ -165,7 +167,7 @@ export function ContextMenu() { // it survives ContextMenu unmount. Closing the menu here avoids the // prior race where the portal dialog's Confirm click was treated as // "outside" by the menu's outside-click handler. - setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name }); + setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) }); closeContextMenu(); }, [contextMenu, setPendingDelete, closeContextMenu]); diff --git a/canvas/src/components/DeleteCascadeConfirmDialog.tsx b/canvas/src/components/DeleteCascadeConfirmDialog.tsx new file mode 100644 index 000000000..e31114b78 --- /dev/null +++ b/canvas/src/components/DeleteCascadeConfirmDialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface Child { + id: string; + name: string; +} + +interface Props { + name: string; + children: Child[]; + checked: boolean; + onCheckedChange: (v: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Cascade-delete confirmation dialog. + * + * When a workspace has children, the operator must explicitly tick + * "I understand this will cascade" before Delete All activates. This + * prevents accidental mass-deletion when ?confirm=true is always sent. + * + * Per WCAG 2.1 SC 2.4.3: focus moves to dialog on open. + * Per WCAG 2.1 SC 3.3.2: labels associated with inputs. + */ +export function DeleteCascadeConfirmDialog({ + name, + children, + checked, + onCheckedChange, + onConfirm, + onCancel, +}: Props) { + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Focus first interactive element when dialog opens (WCAG 2.4.3) + useEffect(() => { + if (!mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [mounted]); + + // Keyboard: Escape cancels, Enter confirms (only when enabled), Tab trapped + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { onCancel(); return; } + if (e.key === "Enter" && checked) { onConfirm(); return; } + if (e.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.hasAttribute("disabled")); + if (focusable.length === 0) { e.preventDefault(); return; } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first.focus(); } + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onCancel, onConfirm, checked]); + + if (!mounted) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+

+ Delete Workspace and Children +

+
+ +
+ {/* Warning */} +
+
+ + + + +
+

+ "{name}" has{" "} + {children.length} child{" "} + {children.length === 1 ? "workspace" : "workspaces"}: +

+
+ + {/* Child list */} +
    + {children.map((c) => ( +
  • {c.name}
  • + ))} +
+ + {/* Cascade warning */} +
+

+ Deleting will cascade — all child workspaces and their data will be permanently removed. This cannot be undone. +

+
+ + {/* Checkbox guard */} + +
+ +
+ + +
+
+
, + document.body + ); +} \ No newline at end of file diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index eac67c658..52f24991b 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -6,26 +6,24 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; // ── Types ───────────────────────────────────────────────────────────────────── -interface MemoryEntry { - key: string; - value: unknown; - version: number; - /** Omitted by the API when there is no TTL (Go omitempty) */ - expires_at?: string; - updated_at: string; +/** Memory entry returned by GET /workspaces/:id/memories */ +export interface MemoryEntry { + id: string; + workspace_id: string; + content: string; + scope: "LOCAL" | "TEAM" | "GLOBAL"; + namespace: string; + created_at: string; /** * Semantic similarity score (0–1). Only present when the API is queried - * with ?q= and the pgvector backend has been deployed (issue #776). + * with ?q= and the pgvector backend has been deployed. * Absent on plain list fetches — renders gracefully without a badge. */ similarity_score?: number; } -interface WriteResult { - status: string; - key: string; - version: number; -} +type Scope = "LOCAL" | "TEAM" | "GLOBAL"; +const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"]; interface Props { workspaceId: string; @@ -34,16 +32,10 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── /** - * Sanitise a memory key for use in an HTML id attribute. - * HTML IDs must not contain whitespace; many non-alphanumeric characters also - * cause selector or ARIA failures. Replace every non-alphanumeric character - * with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones. + * Sanitise a memory id for use in an HTML id attribute. */ -function sanitizeId(key: string): string { - return key - .replace(/[^a-zA-Z0-9]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); +function sanitizeId(id: string): string { + return id.replace(/[^a-zA-Z0-9]/g, "-"); } function formatRelativeTime(iso: string): string { @@ -54,7 +46,7 @@ function formatRelativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } -// ── Skeleton rows — shown during re-fetches when entries already exist ──────── +// ── Skeleton rows ────────────────────────────────────────────────────────────── function MemorySkeletonRows() { return ( @@ -79,20 +71,16 @@ function MemorySkeletonRows() { // ── Component ───────────────────────────────────────────────────────────────── export function MemoryInspectorPanel({ workspaceId }: Props) { + const [activeScope, setActiveScope] = useState("LOCAL"); + const [activeNamespace, setActiveNamespace] = useState(""); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // ── Search state ──────────────────────────────────────────────────────────── - /** Raw input value — updated on every keystroke. */ + // ── Search state (debounced) ──────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(""); - /** - * Debounced value — drives the API fetch. - * Lags searchQuery by 300 ms to avoid hammering the endpoint on every key. - */ const [debouncedQuery, setDebouncedQuery] = useState(""); - // 300 ms debounce: cancel previous timer whenever searchQuery changes. useEffect(() => { const timer = setTimeout( () => setDebouncedQuery(searchQuery.trim()), @@ -101,14 +89,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { return () => clearTimeout(timer); }, [searchQuery]); - // ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects) - - const [expandedKey, setExpandedKey] = useState(null); - const [editingKey, setEditingKey] = useState(null); - const [editValue, setEditValue] = useState(""); - const [editError, setEditError] = useState(null); - const [saving, setSaving] = useState(false); - const [pendingDeleteKey, setPendingDeleteKey] = useState(null); + // ── Delete state ───────────────────────────────────────────────────────────── + const [pendingDeleteId, setPendingDeleteId] = useState(null); // ── Data loading ──────────────────────────────────────────────────────────── @@ -116,12 +98,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setLoading(true); setError(null); try { - const url = debouncedQuery - ? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}` - : `/workspaces/${workspaceId}/memory`; + const params = new URLSearchParams(); + params.set("scope", activeScope); + if (debouncedQuery) params.set("q", debouncedQuery); + if (activeNamespace) params.set("namespace", activeNamespace); + + const url = `/workspaces/${workspaceId}/memories?${params.toString()}`; const data = await api.get(url); + // When a semantic query is active, sort by similarity_score descending. - // Entries without a score (older backend) fall to the end gracefully. const sorted = debouncedQuery ? [...data].sort( (a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0) @@ -129,123 +114,70 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { : data; setEntries(sorted); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load memory entries"); + setError(e instanceof Error ? e.message : "Failed to load memories"); setEntries([]); } finally { setLoading(false); } - }, [workspaceId, debouncedQuery]); + }, [workspaceId, activeScope, debouncedQuery, activeNamespace]); useEffect(() => { loadEntries(); }, [loadEntries]); - // ── Edit handlers ─────────────────────────────────────────────────────────── - - const startEdit = useCallback((entry: MemoryEntry) => { - setEditingKey(entry.key); - setEditValue(JSON.stringify(entry.value, null, 2)); - setEditError(null); - }, []); - - const cancelEdit = useCallback(() => { - setEditingKey(null); - setEditValue(""); - setEditError(null); - }, []); - - const saveEdit = useCallback( - async (entry: MemoryEntry) => { - let parsed: unknown; - try { - parsed = JSON.parse(editValue); - } catch { - setEditError("Invalid JSON — fix the syntax before saving"); - return; - } - - setSaving(true); - setEditError(null); - - // Optimistic update — capture rollback snapshot before mutating - const snapshot = entries; - setEntries((prev) => - prev.map((e) => - e.key === entry.key - ? { - ...e, - value: parsed, - version: e.version + 1, - updated_at: new Date().toISOString(), - } - : e - ) - ); - setEditingKey(null); - setEditValue(""); - - try { - await api.post(`/workspaces/${workspaceId}/memory`, { - key: entry.key, - value: parsed, - if_match_version: entry.version, - }); - } catch (e) { - // Roll back optimistic update on any error - setEntries(snapshot); - setEditingKey(entry.key); - setEditValue(JSON.stringify(entry.value, null, 2)); - const msg = e instanceof Error ? e.message : "Save failed"; - if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) { - setEditError( - "Version conflict — entry changed elsewhere. Reload to see latest." - ); - } else { - setEditError(msg); - } - } finally { - setSaving(false); - } - }, - [entries, editValue, workspaceId] - ); - // ── Delete handlers ───────────────────────────────────────────────────────── const confirmDelete = useCallback(async () => { - if (!pendingDeleteKey) return; - const key = pendingDeleteKey; - setPendingDeleteKey(null); + if (!pendingDeleteId) return; + const id = pendingDeleteId; + setPendingDeleteId(null); // Optimistic removal - setEntries((prev) => prev.filter((e) => e.key !== key)); - if (expandedKey === key) setExpandedKey(null); + setEntries((prev) => prev.filter((e) => e.id !== id)); try { - await api.del( - `/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}` - ); + await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`); } catch (e) { setError(e instanceof Error ? e.message : "Delete failed — reloading..."); await loadEntries(); } - }, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]); + }, [pendingDeleteId, workspaceId, loadEntries]); // ── Render ────────────────────────────────────────────────────────────────── - // Full-screen loader — only on the very first fetch (no entries cached yet). if (loading && entries.length === 0 && !error) { return (
- Loading memory… + Loading memories…
); } return (
- {/* Search bar */} + {/* Scope tabs */}
+
+ {SCOPES.map((scope) => ( + + ))} +
+
+ + {/* Search bar + namespace filter */} +
{/* Magnifying glass icon */} setSearchQuery(e.target.value)} placeholder="Semantic search…" - aria-label="Search memory entries" + aria-label="Search memories" className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors" /> - {/* Clear button — only shown when there is a query */} {searchQuery && ( )}
+ + {/* Namespace filter */} +
+ + setActiveNamespace(e.target.value)} + placeholder="all namespaces" + aria-label="Filter by namespace" + className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0" + /> +
{/* Toolbar */} @@ -290,13 +236,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {debouncedQuery ? `${entries.length} result${entries.length !== 1 ? "s" : ""}` : entries.length === 1 - ? "1 entry" - : `${entries.length} entries`} + ? "1 memory" + : `${entries.length} memories`} @@ -316,11 +262,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {/* Content */}
{loading ? ( - /* Skeleton rows — visible during search-transition re-fetches */ ) : entries.length === 0 ? ( debouncedQuery ? ( - /* Search-specific empty state */

@@ -341,56 +285,40 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {

) : ( - /* Default empty state */
-

No memory entries yet

+

No {activeScope} memories

- Memory entries will appear here when the workspace writes to its KV - store. + {activeScope === "LOCAL" + ? "This workspace has not written any local memories yet." + : activeScope === "TEAM" + ? "No team memories shared with this workspace yet." + : "No global memories exist yet."}

) ) : (
- {entries.map((entry) => { - const isExpanded = expandedKey === entry.key; - const isEditing = editingKey === entry.key; - return ( - { - const next = isExpanded ? null : entry.key; - setExpandedKey(next); - if (!next && isEditing) cancelEdit(); - }} - onEditValueChange={setEditValue} - onStartEdit={() => startEdit(entry)} - onSave={() => saveEdit(entry)} - onCancelEdit={cancelEdit} - onDelete={() => setPendingDeleteKey(entry.key)} - /> - ); - })} + {entries.map((entry) => ( + setPendingDeleteId(entry.id)} + /> + ))}
)}
{/* Delete confirmation dialog */} setPendingDeleteKey(null)} + onCancel={() => setPendingDeleteId(null)} />
); @@ -400,155 +328,97 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { interface MemoryEntryRowProps { entry: MemoryEntry; - isExpanded: boolean; - isEditing: boolean; - editValue: string; - editError: string | null; - saving: boolean; - onToggle: () => void; - onEditValueChange: (v: string) => void; - onStartEdit: () => void; - onSave: () => void; - onCancelEdit: () => void; onDelete: () => void; } -function MemoryEntryRow({ - entry, - isExpanded, - isEditing, - editValue, - editError, - saving, - onToggle, - onEditValueChange, - onStartEdit, - onSave, - onCancelEdit, - onDelete, -}: MemoryEntryRowProps) { - const bodyId = `mem-body-${sanitizeId(entry.key)}`; +function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) { + const [expanded, setExpanded] = useState(false); + const bodyId = `mem-body-${sanitizeId(entry.id)}`; + return (
- {/* Header row — click to expand/collapse */} + {/* Header row */} {/* Expanded body */} - {isExpanded && ( + {expanded && (
- {entry.expires_at && ( -

- Expires: {new Date(entry.expires_at).toLocaleString()} -

- )} - - {isEditing ? ( - /* Edit mode */ -
-