diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 785ad09..183cd72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,12 @@ jobs: working-directory: apps/desktop run: npm run smoke:bootfail + # Guards the process split: engine work must never land back on the UI + # thread, which is what froze the app during first-run setup. + - name: Engine isolation (UI thread stays responsive while indexing) + working-directory: apps/desktop + run: npm run test:isolation + required: name: required runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 578fadf..ec5dd9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ # Run all tests npm test # or directly (mirrors the npm test chain): -bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh +bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh # Run the MCP server (cascade mode) node mcp-server.mjs --manifest layers.json @@ -59,7 +59,7 @@ cd apps/desktop && npm run smoke # headless boot + token-guard check See `docs/architecture/README.md` for the full design. Short version: -- **Storage is federated** — each layer is a `source` behind a uniform adapter: an `okf-local` bundle (git repo of OKF markdown) or an `mcp` foreign graph translated into OKF at read time. +- **Storage is federated** — each layer is a `source` behind a uniform adapter: an `okf-local` bundle, a plain `files` folder, a remote `github` repository, or an `mcp` foreign graph translated into OKF at read time. - **Reading is unified** — `resolver.mjs` stitches the sources into one effective OKF concept at read time. - **Layer precedence** — Personal (3) > Team (2) > Company (0). Higher wins per section. Levels are configurable per layer in the manifest. - **Section/field merge** — not whole-document replacement. A higher layer speaks to what it knows; the rest is inherited. Where layers disagree, the primary value carries per-section `conflicts[]` (dissenting layer + date) — surfaced, not hidden. @@ -71,6 +71,10 @@ Key files: | File | Role | |------|------| | `packages/core/src/resolver.mjs` | Core cascade engine: section merge, precedence, provenance, conflict surfacing | +| `packages/core/src/service.mjs` | Embeddable HTTP service: read API, background index, sources CRUD, settings, file APIs, console mount | +| `packages/core/src/settings.mjs` | User-configurable engine limits (manifest > env > default) + validation and the UI catalog | +| `packages/core/src/layer-files.mjs` | Layer file explorer/editor APIs (`/api/files`, `/api/file`, `/api/section`), sandboxed to layer roots | +| `packages/core/src/http-util.mjs` | Shared HTTP internals: CSRF/loopback guard, containment guard, json/readBody helpers | | `packages/core/src/mcp-server.mjs` | stdio MCP server; resolves via resolver.mjs; renders conflicts in markdown | | `packages/core/src/sources/okf-local.mjs` | OKF-local source adapter: reads OKF markdown bundles from disk. Owns `withDocumentDate` (the section-date fallback chain, shared with every other adapter) and dates undated sections from git history (author dates, batched + TTL-memoized), never the mtime | | `packages/core/src/sources/mcp.mjs` | MCP source adapter: spawns a foreign stdio MCP server, translates to OKF | @@ -83,7 +87,7 @@ Key files: | `packages/core/src/team-activity.mjs` | Aggregates live-layer captures + telemetry NDJSON into control-surface feed and reuse metrics (cross-brain hits) | | `packages/core/fixtures/capture-policy.json` | Routing policy for agent-session captures (kinds → team_candidate; review keywords warn; dominant scratch signals → ignore) | | `examples/team-sync-pack/` | Capture pack: Claude Code plugin (skill + Stop-hook nudge), Cursor rules, Copilot snippet, operator runbook | -| `packages/core/src/sources/index.mjs` | Source factory: builds adapters from a manifest (`okf-local` default, `files`, or `mcp`) | +| `packages/core/src/sources/index.mjs` | Source factory: builds adapters from a manifest (`okf-local` default, `files`, `github`, or `mcp`) | | `examples/mock-mcp-source/server.mjs` | Runnable non-OKF foreign MCP server for integration tests | | `packages/core/src/classify-context.mjs` | Classifies repo events into ignore / local / team_candidate / review_required | | `packages/core/src/ingest.mjs` | Batch classifier: events → signals.json | @@ -94,7 +98,7 @@ Key files: | `apps/okf-browser/` | OKF graph browser | | `apps/playground/` | Interactive playground: dependency-free HTTP server (`server.mjs`) over the engine + canvas/files/sources UI, merge resolver, per-source token budget. See `apps/playground/README.md`. | | `apps/console/` | React + Vite + TS web UI (ContextCake Console) — its own npm package with a build step, deployed to Cloudflare Pages. See `apps/console/README.md` + `apps/console/CLAUDE.md`. | -| `apps/desktop/` | ContextCake for Mac: Electron shell — engine in the main process behind a token-guarded loopback service, console build as renderer, `contextcake` CLI shim, electron-updater. See `apps/desktop/CLAUDE.md` + `specs/contextcake-distribution/design.md`. | +| `apps/desktop/` | ContextCake for Mac: Electron shell — engine in an isolated utility process behind a token-guarded loopback service, console build as renderer, `contextcake` CLI shim, electron-updater. See `apps/desktop/CLAUDE.md` + `specs/contextcake-distribution/design.md`. | | `apps/site/` | Public product site (Astro + Starlight). Spec + boundaries: `specs/contextcake-site/`. Site deps live in `apps/site/package.json` only — the engine stays dependency-free. | | `specs/contextcake-packs/` | Public spec and private-repo template for ContextCake Packs, a separately sold product line whose paid content lives outside this public-bound engine repo. | | `docs/architecture/README.md` | Historical design spec (partially superseded — see note at top) | @@ -113,3 +117,8 @@ Key files: - The engine (`packages/core/src/`) is dependency-free — plain Node.js built-ins only. Do not add npm dependencies without discussion. The exceptions are `apps/console/`, `apps/site/`, and `apps/desktop/` — self-contained npm packages. Console and site never import from the engine; the desktop app imports engine modules by path (one-way: app → engine, never the reverse) and must never cause a dependency to leak into `packages/core`. - `apps/console/` and `apps/site/` each have their own `package.json`, build, and tests; run their commands from that subdirectory, not the repo root. Console CI lives at `.github/workflows/console-*.yml`, path-filtered to `apps/console/**` (production deploys on `console-v*` tags). - Tests create temp directories and clean up with `trap`. Run from the repo root. +- **Disk-backed source walks are async and bounded** (`walkDocs` in `okf-local.mjs`). Never reintroduce sync fs walks or unbounded per-source reads. The desktop app isolates the engine in an Electron utility process so engine work cannot freeze the main/UI process; bounded work still matters because the engine API must remain responsive. +- **Aggregate reads come from a background index and are often partial.** `/api/graph` and `/api/resolve-all` answer from whatever is indexed right now and report `indexing` / `indexingSources`; clients render what they have and poll. Any assertion about completeness (tests, scripts) must pass `?wait=`. `/api/resolve` deliberately reads one concept live. An index entry is keyed by its layer config + settings, so adding a source never re-indexes the existing ones. +- **The indexing limits are user settings, not env-only** (`settings.mjs`, `GET`/`PATCH /api/settings`, Settings → Indexing in the console). Precedence is manifest > env > default — the manifest has to win or the settings UI would silently do nothing. Env vars (`CONTEXTCAKE_MAX_DOC_FILES`, `CONTEXTCAKE_MAX_SCAN_ENTRIES`, `CONTEXTCAKE_SOURCE_BUDGET_MS`) remain the headless/CI fallback. +- **Add-source validation is deliberately cheap**: only "folder is missing" and "that's a file" fail the form. A too-big folder is a normal thing to add — it becomes a visible source error after indexing, with a pointer at Settings. Don't reintroduce a full walk on the add path. MCP sources still probe (`tools/list`) at add time, which is bounded and catches a wrong command. +- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor. diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index aaac014..05894ca 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -41,9 +41,12 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate `resolveConflict`, `send`, view/selection setters) in one context. Callbacks read the freshest values through refs so they don't re-subscribe. State is in-memory only — reloads reset it. -- **Views** — `src/views/` (Canvas, Overview, Triage, Conflicts, Concepts). +- **Views** — `src/views/` (Canvas, Overview, Triage, Conflicts, Concepts, Files). `App.tsx` is the shell: topbar + subbar + routed view, plus the Triage S/R/D keyboard handler. The canvas view stays full-height inside the chrome. + Files is live-mode only: it browses and edits the real files behind each + layer through the engine's `/api/files` + `/api/file`, with a rendered/raw + toggle for Markdown. - **Theming** — every color is a CSS variable in `src/styles.css` (light soft-control-plane default, dark primary surface under `:root[data-theme="dark"]`). `C` in `src/theme.ts` holds the variable references; `css()` parses inline @@ -78,5 +81,15 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens), build won't ship until typecheck is clean. - **Dark-first** — default theme is dark, persisted in `localStorage` under `cc-theme`. Don't assume light. +- **Never block the shell on data.** `store.load.shell` is true only until the + graph responds (milliseconds); concepts resolve after the UI is up, and the + store polls while the engine reports `indexing`. The full-page + "Resolving the cascade…" gate was the first-run hang — don't add another one. + A failed *background* refresh must not clear a working page. +- **`src/markdown.ts` parses to typed data and has no dependencies.** It never + emits HTML; `components/Markdown.tsx` renders document strings as React text + nodes, so source content cannot become markup. Link/image URLs still go + through a scheme allowlist. Preserve that no-HTML boundary when extending it + — see `markdown.test.ts` and `components/Markdown.test.tsx`. - `project/` holds the original Claude Design handoff (prototype HTML, chat, assets). It's provenance, not part of the build — don't import from it. diff --git a/apps/console/src/App.test.tsx b/apps/console/src/App.test.tsx index be0d3c5..81e0d37 100644 --- a/apps/console/src/App.test.tsx +++ b/apps/console/src/App.test.tsx @@ -107,7 +107,7 @@ describe('App settings surface', () => { it('does not open Settings over the Connect Agent dialog', async () => { window.__CC_DESKTOP = { - token: 'test', + getApiToken: async () => 'test', version: '0.1.0', authState: { signedIn: false }, cli: { diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 97959ba..8b5bd6a 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -8,6 +8,7 @@ import { Overview } from './views/Overview' import { Triage } from './views/Triage' import { Conflicts } from './views/Conflicts' import { Concepts } from './views/Concepts' +import { Files } from './views/Files' import { ChatPanel } from './components/ChatPanel' import { SetupWizard } from './components/SetupWizard' import { ConnectAgentDialog } from './components/ConnectAgentDialog' @@ -20,6 +21,9 @@ const ERROR_COPY: Record string> = { 'bad-shape': (msg) => msg, } +// Shown only while the source topology is unknown — a few milliseconds, since +// the engine answers /api/graph from its background index. Reading sources +// happens after the shell is up (see the indexing banner), never in front of it. function LoadingState() { return (
@@ -32,12 +36,33 @@ function LoadingState() { /> ))}
-
Resolving the cascade…
+
Starting ContextCake…
) } +/** + * A non-blocking status strip: the app is fully usable while sources are read. + * It names what is still being indexed so the count changing under the user is + * explained rather than mysterious. + */ +function IndexingBanner({ names }: { names: string[] }) { + const label = names.length === 1 + ? `Indexing ${names[0]}` + : `Indexing ${names.length} sources (${names.slice(0, 3).join(', ')}${names.length > 3 ? ', …' : ''})` + return ( +
+
+ ) +} + function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: string; reload: () => void }) { const text = ERROR_COPY[kind](message) return ( @@ -59,7 +84,7 @@ function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: s } export function App() { - const { view, chatOpen, route, loading, error, reload, mode, sources, loadErrors } = useStore() + const { view, chatOpen, route, loading, load, error, reload, mode, sources, loadErrors } = useStore() // Undefined = not yet decided by the auto-trigger effect below; true/false // once the user (or the trigger) has taken an explicit stance. Kept separate // from `needsSetup` so the wizard's own Success step stays visible even @@ -177,6 +202,7 @@ export function App() { />
setDrawerOpen(true)} /> + {load.indexingSources.length > 0 && } {loadErrors.length > 0 && (
@@ -196,6 +222,7 @@ export function App() { {view === 'triage' && } {view === 'conflicts' && } {view === 'concepts' && } + {view === 'files' && } )}
@@ -208,7 +235,7 @@ export function App() { return ( <>
{body}
- {settingsOpen && } + {settingsOpen && } {showWizard && 0} onClose={closeWizard} onConnectAgent={isDesktop ? () => { setSourceSetupComplete(true) setWizardOpen(false) diff --git a/apps/console/src/api.test.ts b/apps/console/src/api.test.ts index aed2692..7b99a89 100644 --- a/apps/console/src/api.test.ts +++ b/apps/console/src/api.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - adaptConcept, adaptConflicts, adaptSources, LiveDataError, selectMode, + adaptConcept, adaptConflicts, adaptSources, apiFetch, LiveDataError, selectMode, } from './api' import type { GraphSummary, ResolvedConcept } from './types' @@ -64,6 +64,17 @@ describe('LiveSource error taxonomy', () => { await expect(source.graph()).rejects.toBeInstanceOf(LiveDataError) }) + it('reports a request timeout honestly — an eternal "Resolving…" is never an option', async () => { + const { createDataSource } = await import('./api') + vi.mocked(fetch).mockRejectedValue(new DOMException('The operation timed out', 'TimeoutError')) + const source = createDataSource('live') + + await expect(source.graph()).rejects.toMatchObject({ + kind: 'unreachable', + message: expect.stringContaining('took too long'), + }) + }) + it('throws a LiveDataError with kind "bad-status" and the status on non-ok', async () => { const { createDataSource } = await import('./api') vi.mocked(fetch).mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as Response) @@ -94,6 +105,32 @@ describe('LiveSource error taxonomy', () => { }) }) +describe('desktop API credential transport', () => { + afterEach(() => { + delete window.__CC_DESKTOP + vi.unstubAllGlobals() + }) + + it('gets the bearer through trusted IPC instead of renderer process arguments', async () => { + window.__CC_DESKTOP = { + getApiToken: vi.fn().mockResolvedValue('launch-secret'), + version: '0.2.0', + authState: { signedIn: false }, + cli: { getStatus: vi.fn(), install: vi.fn() }, + } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}'))) + + await apiFetch('/api/graph') + + expect(window.__CC_DESKTOP.getApiToken).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith('/api/graph', expect.objectContaining({ + headers: expect.any(Headers), + })) + const [, init] = vi.mocked(fetch).mock.calls[0] + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer launch-secret') + }) +}) + // ---- Adapters: raw engine types -> console view model ------------------- describe('adaptConcept', () => { diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts index 8b2db44..f5155e2 100644 --- a/apps/console/src/api.ts +++ b/apps/console/src/api.ts @@ -39,6 +39,9 @@ export class LiveDataError extends Error { export interface ResolveAllResult { concepts: ResolvedConcept[] errors: { concept: string; error: string }[] + /** Sources still being read — the result is partial until this clears. */ + indexing?: boolean + indexingSources?: string[] } export interface DataSource { @@ -52,18 +55,45 @@ export interface DataSource { // ---- Transport -------------------------------------------------------------- +/** Default deadline for engine API calls — no request may spin forever. */ +export const API_TIMEOUT_MS = 60_000 + /** * fetch for the same-origin engine API. Inside the desktop app the preload - * script injects a per-launch bearer token (`window.__CC_DESKTOP`) that the - * local service requires on every /api route; in a browser this is a plain - * fetch. All renderer code hitting /api/* must go through this. + * bridge retrieves a per-launch bearer token through trusted IPC; the local + * service requires it on every /api route. In a browser this is a plain fetch. + * All renderer code hitting /api/* must go through this. + * + * Every call carries an abort deadline (callers can pass their own `signal` + * to tighten it) so a stalled backend surfaces as a typed error instead of an + * eternal spinner — the setup wizard's "Resolving…" hang. */ -export function apiFetch(path: string, init: RequestInit = {}): Promise { - const token = typeof window !== 'undefined' ? window.__CC_DESKTOP?.token : undefined - if (!token) return fetch(path, init) +let desktopTokenPromise: Promise | null = null + +async function desktopToken(): Promise { + if (typeof window === 'undefined' || !window.__CC_DESKTOP) return undefined + if (!desktopTokenPromise) { + desktopTokenPromise = window.__CC_DESKTOP.getApiToken().catch((error) => { + desktopTokenPromise = null + throw error + }) + } + return desktopTokenPromise +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const withSignal: RequestInit = { ...init, signal: init.signal ?? AbortSignal.timeout(API_TIMEOUT_MS) } + const token = await desktopToken() + if (!token) return fetch(path, withSignal) const headers = new Headers(init.headers) headers.set('authorization', `Bearer ${token}`) - return fetch(path, { ...init, headers }) + return fetch(path, { ...withSignal, headers }) +} + +/** True when a fetch rejection came from the request deadline, not the network. */ +export function isTimeout(e: unknown): boolean { + const name = typeof e === 'object' && e !== null ? (e as { name?: unknown }).name : undefined + return name === 'TimeoutError' || name === 'AbortError' } // ---- Mode selection -------------------------------------------------------- @@ -141,8 +171,13 @@ class LiveSource implements DataSource { let res: Response try { res = await apiFetch(path, { headers: { accept: 'application/json' } }) - } catch { - throw new LiveDataError('unreachable', `Cannot reach the ContextCake server (${path}). Is the playground running?`) + } catch (e) { + throw new LiveDataError( + 'unreachable', + isTimeout(e) + ? `The ContextCake server took too long to respond (${path}). A source may be very large or unreachable.` + : `Cannot reach the ContextCake server (${path}). Is the playground running?`, + ) } if (!res.ok) { throw new LiveDataError('bad-status', `Server returned ${res.status} for ${path}`, res.status) diff --git a/apps/console/src/components/ConnectAgentDialog.test.tsx b/apps/console/src/components/ConnectAgentDialog.test.tsx index 05fb0a8..4583f09 100644 --- a/apps/console/src/components/ConnectAgentDialog.test.tsx +++ b/apps/console/src/components/ConnectAgentDialog.test.tsx @@ -16,7 +16,7 @@ function desktop(status: 'installed' | 'missing' = 'installed') { getStatus = vi.fn().mockResolvedValue({ status, message: `CLI is ${status}.` }) install = vi.fn().mockResolvedValue({ status: 'installed', message: 'Command-line tool installed.' }) window.__CC_DESKTOP = { - token: 'test', + getApiToken: async () => 'test', version: '0.1.0', authState: { signedIn: false }, cli: { diff --git a/apps/console/src/components/Header.tsx b/apps/console/src/components/Header.tsx index 1d3f871..3eb935b 100644 --- a/apps/console/src/components/Header.tsx +++ b/apps/console/src/components/Header.tsx @@ -6,6 +6,7 @@ const TITLES: Record = { triage: ['Review queue', 'Decide what becomes shared knowledge. S stores, R keeps review, D discards.'], conflicts: ['Resolver', 'Compare layer values and lock the effective read.'], concepts: ['Resolved knowledge', 'Browse concepts, sections, and provenance across the cascade.'], + files: ['Context files', 'Read and edit the files behind each source, as Markdown or raw text.'], } /** Content-column header: a mobile menu button, the view title, search, and diff --git a/apps/console/src/components/IndexingSettings.test.tsx b/apps/console/src/components/IndexingSettings.test.tsx new file mode 100644 index 0000000..f23f05a --- /dev/null +++ b/apps/console/src/components/IndexingSettings.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IndexingSettings } from './IndexingSettings' + +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn() })) +vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) + +let container: HTMLDivElement +let root: Root + +const CATALOG = [ + { key: 'maxDocFiles', label: 'Maximum documents per source', help: 'How many files ContextCake will index in one folder.', min: 100, max: 2000000, default: 10000 }, + { key: 'sourceBudgetMs', label: 'Time budget per source', help: 'How long one source may take to index.', min: 1000, max: 600000, default: 30000 }, +] + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +const payload = (settings: Record, stored: Record = {}) => + json({ settings: { maxDocFiles: 10000, sourceBudgetMs: 30000, ...settings }, stored, catalog: CATALOG }) + +function field(key: string): HTMLInputElement { + const input = container.querySelector(`#cc-set-${key}`) + if (!input) throw new Error(`Field not found: ${key}`) + return input +} + +// React's onBlur is delegated from `focusout` — a plain `blur` event does not +// bubble and never reaches the handler. +function blur(input: HTMLInputElement) { + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) +} + +async function type(input: HTMLInputElement, value: string) { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.apiFetch.mockReset() + mocks.apiFetch.mockImplementation(async () => payload({})) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +describe('IndexingSettings', () => { + it('shows the effective limits with plain-language help', async () => { + await act(async () => root.render()) + + expect(field('maxDocFiles').value).toBe('10000') + expect(container.textContent).toContain('How many files ContextCake will index in one folder.') + }) + + it('saves a changed limit on blur', async () => { + const onChanged = vi.fn() + await act(async () => root.render()) + mocks.apiFetch.mockImplementation(async () => payload({ maxDocFiles: 50000 }, { maxDocFiles: 50000 })) + + await type(field('maxDocFiles'), '50000') + await act(async () => blur(field('maxDocFiles'))) + + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ maxDocFiles: 50000 }), + })) + expect(onChanged).toHaveBeenCalledOnce() + }) + + it('rejects an out-of-range value in the form without calling the server', async () => { + await act(async () => root.render()) + mocks.apiFetch.mockClear() + + await type(field('maxDocFiles'), '5') + await act(async () => blur(field('maxDocFiles'))) + + expect(container.textContent).toContain('between') + expect(mocks.apiFetch).not.toHaveBeenCalled() + }) + + it('surfaces a server rejection rather than showing a value that was not saved', async () => { + await act(async () => root.render()) + mocks.apiFetch.mockImplementation(async () => json({ error: 'Maximum documents per source must be between 100 and 2,000,000' }, 400)) + + await type(field('maxDocFiles'), '900') + await act(async () => blur(field('maxDocFiles'))) + + expect(container.textContent).toContain('must be between') + }) + + it('offers Reset only for a value the user has changed', async () => { + mocks.apiFetch.mockImplementation(async () => payload({ maxDocFiles: 50000 }, { maxDocFiles: 50000 })) + await act(async () => root.render()) + + const resets = Array.from(container.querySelectorAll('button')).filter((b) => b.textContent === 'Reset') + expect(resets).toHaveLength(1) // only maxDocFiles is stored + + mocks.apiFetch.mockImplementation(async () => payload({})) + await act(async () => resets[0].click()) + // null clears the stored value, returning the setting to its default. + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({ + body: JSON.stringify({ maxDocFiles: null }), + })) + }) + + it('explains when the engine is unreachable instead of showing blank fields', async () => { + mocks.apiFetch.mockImplementation(async () => json({ error: 'nope' }, 500)) + await act(async () => root.render()) + + expect(container.textContent).toContain('running ContextCake engine') + }) +}) diff --git a/apps/console/src/components/IndexingSettings.tsx b/apps/console/src/components/IndexingSettings.tsx new file mode 100644 index 0000000..b1d3738 --- /dev/null +++ b/apps/console/src/components/IndexingSettings.tsx @@ -0,0 +1,146 @@ +// The Indexing pane of Settings: the limits that decide how much of a folder +// ContextCake will read. These used to be environment variables, which is not +// something anyone should have to edit to index a bigger notes folder. +// +// Values are stored in the manifest and win over the environment, so what this +// screen shows is what the engine actually uses. +import { useEffect, useState } from 'react' +import { apiFetch } from '../api' +import type { SettingDef, SettingsPayload } from '../types' + +interface RowState { + value: string + error: string | null + saving: boolean +} + +function format(n: number): string { + return String(n) +} + +export function IndexingSettings({ onChanged }: { onChanged?: () => void }) { + const [payload, setPayload] = useState(null) + const [loadError, setLoadError] = useState(null) + const [rows, setRows] = useState>({}) + + const apply = (data: SettingsPayload) => { + setPayload(data) + setRows(Object.fromEntries( + data.catalog.map((d) => [d.key, { value: format(data.settings[d.key] ?? d.default), error: null, saving: false }]), + )) + } + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const res = await apiFetch('/api/settings', { headers: { accept: 'application/json' } }) + if (!res.ok) throw new Error(`Server returned ${res.status}`) + const data = (await res.json()) as SettingsPayload + if (!cancelled) apply(data) + } catch (e) { + if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { cancelled = true } + }, []) + + const setRow = (key: string, patch: Partial) => { + setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })) + } + + const commit = async (def: SettingDef, raw: number | null) => { + setRow(def.key, { saving: true, error: null }) + try { + const res = await apiFetch('/api/settings', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ [def.key]: raw }), + }) + const data = await res.json().catch(() => ({}) as { error?: string }) + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + apply(data as SettingsPayload) + onChanged?.() + } catch (e) { + setRow(def.key, { error: e instanceof Error ? e.message : String(e), saving: false }) + } + } + + /** Save on blur — the value is only sent once the user is done typing. */ + const save = (def: SettingDef) => { + const row = rows[def.key] + if (!row) return + const value = Number(row.value) + if (!Number.isFinite(value)) { + setRow(def.key, { error: 'Enter a number.' }) + return + } + if (value < def.min || value > def.max) { + setRow(def.key, { error: `Enter a value between ${def.min.toLocaleString()} and ${def.max.toLocaleString()}.` }) + return + } + if (value === (payload?.settings[def.key] ?? def.default)) return // no change + void commit(def, value) + } + + if (loadError) { + return ( +
+ Indexing settings need a running ContextCake engine. ({loadError}) +
+ ) + } + if (!payload) { + return
Loading indexing settings…
+ } + + return ( +
+

Limits

+
+ {payload.catalog.map((def) => { + const row = rows[def.key] + const isDefault = payload.stored[def.key] === undefined + return ( +
+
+ + + + {def.help} + {row?.error &&

{row.error}

} +
+
+ {!isDefault && ( + + )} + setRow(def.key, { value: e.target.value, error: null })} + onBlur={() => save(def)} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + /> +
+
+ ) + })} +
+

+ Changing a limit re-indexes your sources in the background. ContextCake stays usable while it works. +

+
+ ) +} diff --git a/apps/console/src/components/Markdown.test.tsx b/apps/console/src/components/Markdown.test.tsx new file mode 100644 index 0000000..5facf1c --- /dev/null +++ b/apps/console/src/components/Markdown.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +// Proves the rendered DOM contains no injected markup. The parser produces +// data and React creates every element, so document text lands as text nodes — +// these tests pin that end to end, in a real DOM. +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Markdown } from './Markdown' + +let container: HTMLDivElement +let root: Root + +async function render(source: string) { + await act(async () => root.render()) +} + +beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +describe('Markdown rendering is injection-proof', () => { + it('renders a script tag as visible text, creating no script element', async () => { + await render('') + expect(container.querySelector('script')).toBeNull() + expect(container.textContent).toContain('') + }) + + it('renders an img/onerror payload as text, creating no img element', async () => { + await render('') + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toContain(' { + await render('[click](javascript:alert(1))') + expect(container.querySelector('a')).toBeNull() + expect(container.textContent).toContain('click') + }) + + it('keeps markup inside a code fence literal', async () => { + await render('```\nbold\n```') + expect(container.querySelector('b')).toBeNull() + expect(container.querySelector('pre code')?.textContent).toBe('bold') + }) + + it('does not let a crafted link title break out of the anchor', async () => { + await render('[a"onmouseover="alert(1)](https://example.com)') + const anchor = container.querySelector('a') + expect(anchor?.getAttribute('onmouseover')).toBeNull() + expect(anchor?.getAttribute('href')).toBe('https://example.com') + }) +}) + +describe('Markdown rendering produces the expected elements', () => { + it('renders headings, emphasis and code spans', async () => { + await render('# Title\n\nSome **bold** and `code`.') + expect(container.querySelector('h1')?.textContent).toBe('Title') + expect(container.querySelector('strong')?.textContent).toBe('bold') + expect(container.querySelector('code')?.textContent).toBe('code') + }) + + it('renders task lists with disabled checkboxes', async () => { + await render('- [x] done\n- [ ] todo') + const boxes = container.querySelectorAll('input[type="checkbox"]') + expect(boxes).toHaveLength(2) + expect(boxes[0].checked).toBe(true) + expect(boxes[0].disabled).toBe(true) + expect(boxes[1].checked).toBe(false) + }) + + it('renders tables inside a scroll container', async () => { + await render('| a | b |\n| --- | --- |\n| 1 | 2 |') + expect(container.querySelector('.cc-md-tablewrap table')).toBeTruthy() + expect(container.querySelector('th')?.textContent).toBe('a') + expect(container.querySelectorAll('td')[1]?.textContent).toBe('2') + }) + + it('marks outbound links noopener', async () => { + await render('[a](https://example.com)') + expect(container.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer') + }) +}) diff --git a/apps/console/src/components/Markdown.tsx b/apps/console/src/components/Markdown.tsx new file mode 100644 index 0000000..b2c45a4 --- /dev/null +++ b/apps/console/src/components/Markdown.tsx @@ -0,0 +1,99 @@ +// Renders parsed Markdown (src/markdown.ts) as React elements. +// +// Everything from the document arrives as a text node or an attribute React +// creates itself, so document content cannot become markup. There is no HTML +// string anywhere in this path and no `dangerouslySetInnerHTML` — a note that +// contains `') + expect(nodes).toEqual([{ type: 'text', value: '' }]) + }) + + it('parses code spans without re-scanning their contents', () => { + expect(parseInline('`x`')).toEqual([{ type: 'code', value: 'x' }]) + // Markup inside a code span stays literal. + expect(parseInline('`**not bold**`')).toEqual([{ type: 'code', value: '**not bold**' }]) + }) + + it('does not confuse literal text with an internal placeholder', () => { + // The previous string-based renderer used ` CODE0 ` markers, which a + // document could collide with. There are no placeholders now. + const nodes = parseInline('`x` and the CODE0 constant') + expect(textOf(nodes)).toBe('x and the CODE0 constant') + }) + + it('parses emphasis, strong and strikethrough', () => { + expect(parseInline('**bold**')).toEqual([{ type: 'strong', children: [{ type: 'text', value: 'bold' }] }]) + expect(parseInline('*italic*')).toEqual([{ type: 'em', children: [{ type: 'text', value: 'italic' }] }]) + expect(parseInline('~~gone~~')).toEqual([{ type: 'del', children: [{ type: 'text', value: 'gone' }] }]) + }) + + it('parses nested inline markup without re-scanning from the start', () => { + // Regression: the scanner regex was module-level and global, so a + // recursive call (emphasis/link contents) reset its lastIndex and the + // outer loop restarted forever. This input hangs on that bug. + const nodes = parseInline('**bold with *nested* emphasis** and [a **link**](https://example.com)') + expect(textOf(nodes)).toBe('bold with nested emphasis and a link') + expect(nodes.some((n) => n.type === 'link')).toBe(true) + }) + + it('parses links and OKF wiki links', () => { + const [link] = parseInline('[a](https://example.com)') + expect(link).toMatchObject({ type: 'link', href: 'https://example.com' }) + expect(parseInline('[[decisions/primary-db]]')).toEqual([{ type: 'wikilink', value: 'decisions/primary-db' }]) + }) +}) + +describe('parseMarkdown', () => { + it('parses headings and drops OKF heading attributes', () => { + expect(parseMarkdown('## Engine {#engine updated=2026-04-01}')).toEqual([ + { type: 'heading', level: 2, content: [{ type: 'text', value: 'Engine' }] }, + ]) + }) + + it('captures fenced code verbatim, including markup', () => { + const [block] = parseMarkdown('```html\n\n```') + expect(block).toEqual({ type: 'code', lang: 'html', text: '' }) + }) + + it('parses bullet, ordered and task lists', () => { + const [bullets] = parseMarkdown('- one\n- two') + expect(bullets).toMatchObject({ type: 'list', ordered: false }) + expect(parseMarkdown('1. one')[0]).toMatchObject({ type: 'list', ordered: true }) + const [tasks] = parseMarkdown('- [x] done\n- [ ] todo') + expect(tasks).toMatchObject({ + type: 'list', + items: [{ task: true, checked: true }, { task: true, checked: false }], + }) + }) + + it('parses tables into head and rows', () => { + const [table] = parseMarkdown('| a | b |\n| --- | --- |\n| 1 | 2 |') + expect(table).toMatchObject({ type: 'table' }) + if (table.type !== 'table') throw new Error('expected a table') + expect(table.head.map(textOf)).toEqual(['a', 'b']) + expect(table.rows[0].map(textOf)).toEqual(['1', '2']) + }) + + it('parses quotes and rules', () => { + expect(parseMarkdown('> quoted')[0]).toMatchObject({ type: 'quote' }) + expect(parseMarkdown('---')).toEqual([{ type: 'rule' }]) + }) + + it('joins wrapped lines into one paragraph', () => { + const [para] = parseMarkdown('one\ntwo') + expect(para).toMatchObject({ type: 'paragraph' }) + if (para.type !== 'paragraph') throw new Error('expected a paragraph') + expect(textOf(para.content)).toBe('one two') + }) + + it('handles empty input', () => { + expect(parseMarkdown('')).toEqual([]) + }) + + it('parses a long table divider quickly (no catastrophic backtracking)', () => { + // The obvious divider regex backtracks polynomially on input like this. + const line = `| ${'-'.repeat(4000)}` + const started = Date.now() + parseMarkdown(`| a |\n${line}\n`) + expect(Date.now() - started).toBeLessThan(500) + }) +}) diff --git a/apps/console/src/markdown.ts b/apps/console/src/markdown.ts new file mode 100644 index 0000000..c885f95 --- /dev/null +++ b/apps/console/src/markdown.ts @@ -0,0 +1,248 @@ +// A small, deliberately limited Markdown parser for the Files view. +// +// It produces DATA, never HTML. Rendering is React's job +// (components/Markdown.tsx), which means document text reaches the DOM as text +// nodes and can never be interpreted as markup — there is no HTML string, no +// `dangerouslySetInnerHTML`, and no hand-rolled escaping to get subtly wrong. +// That is the whole security posture: don't sanitize, don't build HTML. +// +// The one thing still filtered here is URLs, because a `javascript:` href is +// dangerous even inside a properly-created React element. Anything outside a +// small navigational allowlist is dropped and the link renders as plain text. +// +// Not a spec-complete CommonMark implementation and not trying to be: it +// covers what appears in real context notes (headings, emphasis, code, lists, +// quotes, tables, links, images, rules) and degrades to plain text otherwise. +// The console has no markdown dependency and this keeps it that way — see +// apps/console/CLAUDE.md on the dependency posture. + +export type Inline = + | { type: 'text'; value: string } + | { type: 'code'; value: string } + | { type: 'strong'; children: Inline[] } + | { type: 'em'; children: Inline[] } + | { type: 'del'; children: Inline[] } + | { type: 'link'; href: string; children: Inline[] } + | { type: 'image'; src: string; alt: string } + | { type: 'wikilink'; value: string } + +export interface ListItem { + content: Inline[] + task: boolean + checked: boolean +} + +export type Block = + | { type: 'heading'; level: number; content: Inline[] } + | { type: 'paragraph'; content: Inline[] } + | { type: 'code'; lang: string | null; text: string } + | { type: 'list'; ordered: boolean; items: ListItem[] } + | { type: 'quote'; content: Inline[] } + | { type: 'rule' } + /** `head` is a row of cells; `rows` is a list of such rows. */ + | { type: 'table'; head: Inline[][]; rows: Inline[][][] } + +const SAFE_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/)/i + +/** Allow only navigational schemes; anything else is not a link at all. */ +export function safeUrl(raw: string): string | null { + const url = raw.trim() + if (!url || !SAFE_SCHEME.test(url)) return null + return url +} + +// One pass, alternation ordered by precedence. Code spans come first so their +// contents are never re-scanned as markup — no placeholder substitution, and +// therefore no way for literal text to collide with a placeholder. +// +// Kept as a SOURCE string, not a shared RegExp: parseInline recurses (emphasis +// and link contents), and a shared /g regex would have its lastIndex reset by +// the inner call, restarting the outer scan forever. +const INLINE_SOURCE = [ + '(`[^`]+`)', // 1 code + '(!\\[[^\\]]*\\]\\([^)\\s]+\\))', // 2 image + '(\\[\\[[^\\]]+\\]\\])', // 3 wiki link + '(\\[[^\\]]+\\]\\([^)\\s]+\\))', // 4 link + '(\\*\\*\\*[^*]+\\*\\*\\*)', // 5 strong+em + // Strong may contain single asterisks so `**bold *and italic* bold**` + // nests. The two alternatives are disjoint (a non-asterisk, or an asterisk + // not followed by one), so this cannot backtrack ambiguously. + '(\\*\\*(?:[^*]|\\*(?!\\*))+\\*\\*)', // 6 strong + '(\\*[^*\\n]+\\*)', // 7 em + '(~~[^~]+~~)', // 8 del +].join('|') + +function text(value: string): Inline[] { + return value ? [{ type: 'text', value }] : [] +} + +export function parseInline(source: string): Inline[] { + const out: Inline[] = [] + let last = 0 + const pattern = new RegExp(INLINE_SOURCE, 'g') // per call — see INLINE_SOURCE + let match: RegExpExecArray | null + + while ((match = pattern.exec(source)) !== null) { + if (match.index > last) out.push(...text(source.slice(last, match.index))) + last = match.index + match[0].length + const [, code, image, wiki, link, strongEm, strong, em, del] = match + + if (code !== undefined) { + out.push({ type: 'code', value: code.slice(1, -1) }) + } else if (image !== undefined) { + const parts = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(image) + const src = parts ? safeUrl(parts[2]) : null + // A rejected URL is not silently dropped — the alt text stays visible. + if (parts && src) out.push({ type: 'image', src, alt: parts[1] }) + else out.push(...text(image)) + } else if (wiki !== undefined) { + out.push({ type: 'wikilink', value: wiki.slice(2, -2) }) + } else if (link !== undefined) { + const parts = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(link) + const href = parts ? safeUrl(parts[2]) : null + if (parts && href) out.push({ type: 'link', href, children: parseInline(parts[1]) }) + else out.push(...text(link)) + } else if (strongEm !== undefined) { + out.push({ type: 'strong', children: [{ type: 'em', children: parseInline(strongEm.slice(3, -3)) }] }) + } else if (strong !== undefined) { + out.push({ type: 'strong', children: parseInline(strong.slice(2, -2)) }) + } else if (em !== undefined) { + out.push({ type: 'em', children: parseInline(em.slice(1, -1)) }) + } else if (del !== undefined) { + out.push({ type: 'del', children: parseInline(del.slice(2, -2)) }) + } + } + if (last < source.length) out.push(...text(source.slice(last))) + return out +} + +// Single character class, no ambiguous repetition — the obvious +// `[\s:|-]+\|[\s:|-]*` form can backtrack polynomially on a long divider. +function isTableDivider(line: string): boolean { + const trimmed = line.trim() + if (!trimmed.includes('-') || !trimmed.includes('|')) return false + return /^[\s:|-]+$/.test(trimmed) +} + +function splitRow(line: string): string[] { + return line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((cell) => cell.trim()) +} + +export function parseMarkdown(src: string): Block[] { + const lines = String(src ?? '').split(/\r?\n/) + const blocks: Block[] = [] + let i = 0 + let list: { ordered: boolean; items: ListItem[] } | null = null + + const closeList = () => { + if (list) { blocks.push({ type: 'list', ordered: list.ordered, items: list.items }); list = null } + } + const pushItem = (ordered: boolean, item: ListItem) => { + if (!list || list.ordered !== ordered) { closeList(); list = { ordered, items: [] } } + list.items.push(item) + } + + while (i < lines.length) { + const line = lines[i] + + // Fenced code: contents are captured verbatim as text, never parsed. + const fence = /^\s{0,3}(```|~~~)(.*)$/.exec(line) + if (fence) { + closeList() + const marker = fence[1] + const lang = fence[2].trim().split(/\s+/)[0] || null + const body: string[] = [] + i += 1 + while (i < lines.length && !lines[i].trimStart().startsWith(marker)) { + body.push(lines[i]) + i += 1 + } + i += 1 // consume the closing fence + blocks.push({ type: 'code', lang, text: body.join('\n') }) + continue + } + + if (!line.trim()) { closeList(); i += 1; continue } + + if (/^\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + closeList() + blocks.push({ type: 'rule' }) + i += 1 + continue + } + + const heading = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line) + if (heading) { + closeList() + // Drop OKF heading attributes ({#key updated=…}) — they're metadata, and + // the Files view shows them in the raw tab. + const content = heading[2].replace(/\{[^}]*\}\s*$/, '').trim() + blocks.push({ type: 'heading', level: heading[1].length, content: parseInline(content) }) + i += 1 + continue + } + + // Table: a header row followed by a divider row. + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) { + closeList() + const head = splitRow(line).map(parseInline) + i += 2 + const rows: Inline[][][] = [] + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + rows.push(splitRow(lines[i]).map(parseInline)) + i += 1 + } + blocks.push({ type: 'table', head, rows }) + continue + } + + const quote = /^\s{0,3}>\s?(.*)$/.exec(line) + if (quote) { + closeList() + const body: string[] = [quote[1]] + i += 1 + while (i < lines.length) { + const next = /^\s{0,3}>\s?(.*)$/.exec(lines[i]) + if (!next) break + body.push(next[1]) + i += 1 + } + blocks.push({ type: 'quote', content: parseInline(body.join(' ')) }) + continue + } + + const task = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/.exec(line) + if (task) { + pushItem(false, { content: parseInline(task[2]), task: true, checked: task[1].toLowerCase() === 'x' }) + i += 1 + continue + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line) + if (bullet) { + pushItem(false, { content: parseInline(bullet[1]), task: false, checked: false }) + i += 1 + continue + } + + const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line) + if (ordered) { + pushItem(true, { content: parseInline(ordered[1]), task: false, checked: false }) + i += 1 + continue + } + + // Paragraph: consume until a blank line or the start of another block. + closeList() + const para: string[] = [line] + i += 1 + while (i < lines.length && lines[i].trim() && !/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|```|~~~|-{3,}\s*$)/.test(lines[i])) { + para.push(lines[i]) + i += 1 + } + blocks.push({ type: 'paragraph', content: parseInline(para.join(' ')) }) + } + + closeList() + return blocks +} diff --git a/apps/console/src/store.test.tsx b/apps/console/src/store.test.tsx new file mode 100644 index 0000000..3427108 --- /dev/null +++ b/apps/console/src/store.test.tsx @@ -0,0 +1,195 @@ +// @vitest-environment jsdom +// The load contract: the shell must never wait on source reading. +// +// The regression these cover is the first-run hang — the whole app sat behind +// a "Resolving the cascade…" screen until every source had been read, which on +// a large folder meant minutes of an unusable window. +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { StoreProvider, useStore } from './store' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), resolveAll: vi.fn() })) + +vi.mock('./api', async () => { + const actual = await vi.importActual('./api') + return { + ...actual, + createDataSource: () => ({ + mode: 'live' as const, + graph: mocks.graph, + resolveAll: mocks.resolveAll, + resolve: vi.fn(), + listConcepts: vi.fn(), + }), + } +}) + +let container: HTMLDivElement +let root: Root + +/** Renders the store's load state so assertions read it from the DOM. */ +function Probe() { + const { load, concepts, sources, reload } = useStore() + return ( +
+ ) +} + +const probe = () => container.firstElementChild as HTMLElement + +function graphPayload(indexingSources: string[]) { + return { + totals: { sourceTokens: 0, resolvedTokens: 0, concepts: 0, sources: 1 }, + indexing: indexingSources.length > 0, + indexingSources, + sources: [{ + name: 'personal', level: 3, kind: 'files', conceptCount: 0, tokens: 0, + latestUpdated: null, status: indexingSources.length ? 'indexing' : 'ok', error: null, + }], + concepts: [], + } +} + +function conceptPayload(id: string) { + return { + id, + contributors: [{ layer: 'personal', level: 3, updated: '2026-01-01' }], + frontmatter: { title: id, type: 'note' }, + sections: [{ key: 'body', heading: '## Body {#body}', content: 'x', sourceLayer: 'personal', sourceUpdated: '2026-01-01' }], + } +} + +beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.graph.mockReset() + mocks.resolveAll.mockReset() + vi.useFakeTimers() +}) + +afterEach(async () => { + vi.useRealTimers() + await act(async () => root.unmount()) + container.remove() +}) + +describe('store load state', () => { + it('unblocks the shell as soon as the graph responds, before concepts resolve', async () => { + let releaseResolveAll: (v: unknown) => void = () => {} + mocks.graph.mockResolvedValue(graphPayload(['personal'])) + mocks.resolveAll.mockReturnValue(new Promise((res) => { releaseResolveAll = res })) + + await act(async () => root.render()) + + // resolve-all is still pending — the shell must already be up. + expect(probe().dataset.shell).toBe('false') + expect(probe().dataset.sources).toBe('1') + expect(probe().dataset.concepts).toBe('true') + + await act(async () => { + releaseResolveAll({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + }) + expect(probe().dataset.count).toBe('1') + expect(probe().dataset.concepts).toBe('false') + expect(probe().dataset.indexing).toBe('') + }) + + it('reports which sources are still indexing so the UI can say so', async () => { + mocks.graph.mockResolvedValue(graphPayload(['team', 'company'])) + mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: true, indexingSources: ['team', 'company'] }) + + await act(async () => root.render()) + + expect(probe().dataset.indexing).toBe('team,company') + expect(probe().dataset.shell).toBe('false') + }) + + it('keeps an already-loaded shell mounted during a refresh', async () => { + let releaseRefresh: (v: unknown) => void = () => {} + mocks.graph + .mockResolvedValueOnce(graphPayload([])) + .mockReturnValueOnce(new Promise((resolve) => { releaseRefresh = resolve })) + mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + + await act(async () => root.render()) + expect(probe().dataset.shell).toBe('false') + + await act(async () => container.querySelector('button')?.click()) + expect(probe().dataset.shell).toBe('false') + + await act(async () => releaseRefresh(graphPayload([]))) + }) + + it('polls while indexing and fills in concepts as they land', async () => { + mocks.graph + .mockResolvedValueOnce(graphPayload(['personal'])) + .mockResolvedValue(graphPayload([])) + mocks.resolveAll + .mockResolvedValueOnce({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] }) + .mockResolvedValue({ concepts: [conceptPayload('a'), conceptPayload('b')], errors: [], indexing: false }) + + await act(async () => root.render()) + expect(probe().dataset.count).toBe('0') + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + expect(probe().dataset.count).toBe('2') + expect(probe().dataset.concepts).toBe('false') + expect(probe().dataset.indexing).toBe('') + }) + + it('retries a stumble mid-index instead of leaving the banner running forever', async () => { + mocks.graph.mockResolvedValue(graphPayload(['personal'])) + mocks.resolveAll + .mockResolvedValueOnce({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] }) + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false }) + + await act(async () => root.render()) + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(probe().dataset.count).toBe('1') + expect(probe().dataset.concepts).toBe('false') + }) + + it('stops claiming to index after repeated failures rather than spinning', async () => { + mocks.graph + .mockResolvedValueOnce(graphPayload(['personal'])) + .mockRejectedValue(new Error('server went away')) + mocks.resolveAll.mockResolvedValueOnce({ + concepts: [], errors: [], indexing: true, indexingSources: ['personal'], + }) + + await act(async () => root.render()) + expect(probe().dataset.indexing).toBe('personal') + + await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) + + // The banner must not outlive the polling that would clear it. + expect(probe().dataset.indexing).toBe('') + expect(probe().dataset.concepts).toBe('false') + }) + + it('does not treat an empty mid-index pass as a fatal error', async () => { + mocks.graph.mockResolvedValue(graphPayload(['personal'])) + // Zero concepts AND errors, but indexing is still running: not a failure. + mocks.resolveAll.mockResolvedValue({ + concepts: [], errors: [{ concept: 'x', error: 'not found in any healthy source' }], + indexing: true, indexingSources: ['personal'], + }) + + await act(async () => root.render()) + + expect(probe().dataset.shell).toBe('false') + expect(probe().dataset.sources).toBe('1') + }) +}) diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index 9a1b028..37d5c89 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -10,10 +10,10 @@ import { } from './api' import type { LayerId, RouteId } from './theme' -export type ViewId = 'canvas' | 'overview' | 'triage' | 'conflicts' | 'concepts' +export type ViewId = 'canvas' | 'overview' | 'triage' | 'conflicts' | 'concepts' | 'files' export type TriageTab = 'review' | 'captured' | 'ignored' -const VIEW_IDS: ViewId[] = ['canvas', 'overview', 'triage', 'conflicts', 'concepts'] +const VIEW_IDS: ViewId[] = ['canvas', 'overview', 'triage', 'conflicts', 'concepts', 'files'] /** Parse the URL hash into a view + optional concept id (deep link). */ function parseHash(): { view?: ViewId; concept?: string } { @@ -72,9 +72,20 @@ function cannedAnswer(q: string): { text: string; cites: Cite[]; note?: string } return { text: 'I resolved that across all three layers but found nothing specific. Try asking about the database, auth tokens, deploys, or incident response.', cites: [] } } +/** What the shell is waiting on. `shell` is the only state that blocks the UI. */ +export interface LoadState { + /** True only until the source topology is known — milliseconds, not minutes. */ + shell: boolean + /** True while concepts are still being resolved in the background. */ + concepts: boolean + /** Sources the engine is still reading. */ + indexingSources: string[] +} + export interface Store { mode: Mode loading: boolean + load: LoadState error: LiveDataError | null view: ViewId @@ -121,8 +132,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { const mode = source.mode const [loading, setLoading] = useState(true) + const [conceptsLoading, setConceptsLoading] = useState(true) + const [indexingSources, setIndexingSources] = useState([]) const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) + const shellReadyRef = useRef(false) const [concepts, setConcepts] = useState([]) const [sources, setSources] = useState([]) @@ -144,44 +158,91 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [chatInput, setChatInput] = useState('') const [chatMessages, setChatMessages] = useState(initialMessages) - // Load the cascade from the data source (demo bundle or live playground API). + // Load the cascade in two stages so the app is usable immediately. + // + // Stage 1 (the graph) only needs the source topology, which the engine + // answers from its background index in milliseconds — that unblocks the + // shell. Stage 2 (resolve-all) fills in concepts and conflicts as they + // arrive. While the engine is still reading sources it says so, and we poll + // rather than making the user stare at a blocked screen: that full-page + // "Resolving the cascade…" wait was the hang people hit on first run. useEffect(() => { let cancelled = false - void (async () => { - setLoading(true) - setError(null) + let timer: ReturnType | undefined + let failures = 0 + const MAX_POLL_FAILURES = 3 + + const pass = async (first: boolean) => { try { const g = await source.graph() - const { concepts: raw, errors } = await source.resolveAll() - // Only fail the whole page when nothing resolved; partial failures - // render what loaded and surface the rest. - if (raw.length === 0 && errors.length > 0) { + if (cancelled) return + setSources(adaptSources(g)) + setIndexingSources(g.indexingSources ?? []) + setError(null) + shellReadyRef.current = true + setLoading(false) // the shell can render now — everything else streams in + + const { concepts: raw, errors, indexing, indexingSources: resolvingSources } = await source.resolveAll() + if (cancelled) return + // Only fail the whole page when nothing resolved AND nothing is still + // being read; a partial pass mid-index is expected, not an error. + if (raw.length === 0 && errors.length > 0 && !indexing) { throw new LiveDataError('bad-shape', `No concept resolved (first error: ${errors[0].concept}: ${errors[0].error})`) } - if (cancelled) return setLoadErrors(errors) - setSources(adaptSources(g)) + // The graph and resolve-all requests are separate snapshots. Indexing + // can finish between them; use the later answer so a stale graph never + // leaves the banner running after polling has stopped. + setIndexingSources(indexing ? (resolvingSources ?? g.indexingSources ?? []) : []) setConcepts(raw.map(adaptConcept)) const derivedConflicts = adaptConflicts(raw) setConflicts(derivedConflicts) - // Honor a deep-linked concept from the URL hash; else default to the first. - const pending = pendingConceptRef.current - if (pending && raw.some((c) => c.id === pending)) { + // Honor a deep-linked concept from the URL hash; else default to the + // first. Only claim the deep link once it actually resolved. + const pendingId = pendingConceptRef.current + if (pendingId && raw.some((c) => c.id === pendingId)) { setView('concepts') - setSelConcept(pending) - } else { + setSelConcept(pendingId) + pendingConceptRef.current = undefined + } else if (!indexing) { setSelConcept((prev) => prev || raw[0]?.id || '') + pendingConceptRef.current = undefined } - pendingConceptRef.current = undefined setSelConflict((prev) => prev || derivedConflicts[0]?.id || '') + setConceptsLoading(Boolean(indexing)) + failures = 0 + if (indexing) timer = setTimeout(() => void pass(false), 900) } catch (e) { if (cancelled) return - setError(e instanceof LiveDataError ? e : new LiveDataError('bad-shape', e instanceof Error ? e.message : String(e))) - } finally { - if (!cancelled) setLoading(false) + // A failure on a background refresh must not blow away a working page. + if (first && !shellReadyRef.current) { + setError(e instanceof LiveDataError ? e : new LiveDataError('bad-shape', e instanceof Error ? e.message : String(e))) + setLoading(false) + setConceptsLoading(false) + return + } + // Retry a stumble mid-index with backoff. Giving up silently here would + // leave the "Indexing…" banner running forever with nothing refreshing + // it, which reads as a hang — the exact impression to avoid. + failures += 1 + if (failures <= MAX_POLL_FAILURES) { + timer = setTimeout(() => void pass(false), 900 * failures) + return + } + setConceptsLoading(false) + setIndexingSources([]) // stop claiming work is still in flight } - })() - return () => { cancelled = true } + } + + // A refresh must not replace an already-usable shell with a full-page + // loader. Besides the visual regression, doing so unmounts the Files editor + // and can discard an unsaved draft. Only the initial bootstrap owns the + // shell-level loading state. + if (!shellReadyRef.current) setLoading(true) + setConceptsLoading(true) + setError(null) + void pass(true) + return () => { cancelled = true; if (timer) clearTimeout(timer) } }, [source, reloadKey]) // Refs so callbacks read the freshest values without re-subscribing. @@ -302,15 +363,20 @@ export function StoreProvider({ children }: { children: ReactNode }) { const reload = useCallback(() => setReloadKey((k) => k + 1), []) + const load = useMemo( + () => ({ shell: loading, concepts: conceptsLoading, indexingSources }), + [loading, conceptsLoading, indexingSources], + ) + const value = useMemo(() => ({ - mode, loading, error, + mode, loading, load, error, view, triageTab, selSignal, selConflict, selConcept, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, openChat: () => setChatOpen(true), closeChat: () => setChatOpen(false), setChatInput, filtered, route, resolveConflict, send, reload, - }), [mode, loading, error, view, triageTab, selSignal, selConflict, selConcept, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, filtered, route, resolveConflict, send, reload]) + }), [mode, loading, load, error, view, triageTab, selSignal, selConflict, selConcept, query, chatOpen, chatBusy, chatInput, chatMessages, concepts, sources, signals, conflicts, activity, loadErrors, filtered, route, resolveConflict, send, reload]) return {children} } diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index 6c8dd2c..9ba3f91 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1586,3 +1586,64 @@ textarea:focus-visible, .cc-src-row { grid-template-columns: 22px minmax(0, 1fr) auto auto; gap: 10px; } .cc-src-cov { display: none; } /* the coverage bar drops on small screens */ } + +/* ---- Rendered markdown (Files view) -------------------------------------- + Content styles only. The HTML is produced by src/markdown.ts, which escapes + its input first and emits a fixed tag set — no raw HTML from a document + ever reaches the DOM. */ +.cc-md { color: var(--cc-body); font-size: 13.5px; line-height: 1.68; } +.cc-md > :first-child { margin-top: 0; } +.cc-md h1, .cc-md h2, .cc-md h3, .cc-md h4, .cc-md h5, .cc-md h6 { + margin: 1.6em 0 0.5em; color: var(--cc-ink); font-weight: 700; line-height: 1.3; +} +.cc-md h1 { font-size: 1.5em; } +.cc-md h2 { font-size: 1.25em; } +.cc-md h3 { font-size: 1.08em; } +.cc-md h4, .cc-md h5, .cc-md h6 { font-size: 1em; } +.cc-md p { margin: 0 0 1em; } +.cc-md ul, .cc-md ol { margin: 0 0 1em; padding-left: 1.4em; } +.cc-md li { margin: 0.25em 0; } +.cc-md li.cc-md-task { list-style: none; margin-left: -1.2em; } +.cc-md li.cc-md-task input { margin-right: 0.5em; } +.cc-md a { color: var(--cc-teal-text); text-underline-offset: 2px; } +.cc-md code { + font-family: var(--cc-mono, ui-monospace, monospace); font-size: 0.9em; + background: var(--cc-surface); border: 1px solid var(--cc-line); + border-radius: 5px; padding: 0.1em 0.35em; +} +.cc-md pre { + margin: 0 0 1em; padding: 12px 14px; overflow-x: auto; + background: var(--cc-surface); border: 1px solid var(--cc-line); border-radius: 10px; +} +.cc-md pre code { background: none; border: none; padding: 0; font-size: 12px; line-height: 1.6; } +.cc-md blockquote { + margin: 0 0 1em; padding: 2px 0 2px 14px; + border-left: 3px solid var(--cc-teal-stroke); color: var(--cc-caption); +} +.cc-md hr { margin: 1.6em 0; border: none; border-top: 1px solid var(--cc-line); } +.cc-md img { max-width: 100%; height: auto; border-radius: 8px; } +.cc-md-wikilink { + font-family: var(--cc-mono, ui-monospace, monospace); font-size: 0.9em; + color: var(--cc-teal-text); background: var(--cc-teal-fill); + border: 1px solid var(--cc-teal-stroke); border-radius: 5px; padding: 0.05em 0.35em; +} +/* Wide tables scroll inside their own box — the page never scrolls sideways. */ +.cc-md-tablewrap { overflow-x: auto; margin: 0 0 1em; } +.cc-md table { border-collapse: collapse; font-size: 12.5px; } +.cc-md th, .cc-md td { padding: 7px 11px; border: 1px solid var(--cc-line); text-align: left; } +.cc-md th { background: var(--cc-surface); font-weight: 600; color: var(--cc-ink); } + +/* ---- Settings: numeric limit rows ---------------------------------------- */ +.cc-settings-number { + width: 132px; box-sizing: border-box; padding: 7px 10px; text-align: right; + border-radius: 8px; border: 1px solid var(--cc-line); + background: var(--cc-raised); color: var(--cc-ink); font: inherit; font-size: 13px; + font-family: var(--cc-mono, ui-monospace, monospace); +} +.cc-settings-number:disabled { opacity: 0.55; cursor: not-allowed; } +.cc-settings-rowerr { margin: 6px 0 0; font-size: 11.5px; color: var(--cc-amber-text); } +.cc-settings-reset { + padding: 6px 12px; border-radius: 8px; cursor: pointer; font: inherit; + font-size: 12px; font-weight: 600; + border: 1px solid var(--cc-line); background: transparent; color: var(--cc-caption); +} diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts index af67b3a..c7c6404 100644 --- a/apps/console/src/types.ts +++ b/apps/console/src/types.ts @@ -42,6 +42,16 @@ export interface ResolvedConcept { sections: ResolvedSection[] } +/** Background-index progress for one source. */ +export interface IndexProgress { + status: 'indexing' | 'ready' | 'error' + /** 'queued' | 'scanning' | 'loading' | 'ready' | 'error' */ + phase: string + loaded: number + total: number | null + elapsedMs: number +} + /** A source (layer) row in the graph summary. */ export interface GraphSource { name: string @@ -53,12 +63,16 @@ export interface GraphSource { tokens: number latestUpdated: string | null /** - * 'ok' | 'degraded' | 'error'. 'degraded' is a source that listed but whose - * adapter reports its last request failed — it is serving cached or partial - * content and still resolves, unlike 'error', which contributed nothing. + * 'ok' | 'indexing' | 'degraded' | 'error'. 'indexing' is a source the + * background index has not finished reading yet. 'degraded' is a source that + * listed but whose adapter reports its last request failed — it is serving + * cached or partial content and still resolves, unlike 'error', which + * contributed nothing. */ status: string error: string | null + /** Background-index progress; absent on sources that never index. */ + indexing?: IndexProgress /** ISO timestamps from adapters that track health (remote sources); else null. */ lastErrorAt?: string | null lastSuccessAt?: string | null @@ -79,11 +93,65 @@ export interface GraphConcept { export interface GraphSummary { manifest?: { path: string } tokenizer?: string + /** True while any source is still being read; the payload is partial. */ + indexing?: boolean + indexingSources?: string[] totals: { sourceTokens: number; resolvedTokens: number; concepts: number; sources: number } sources: GraphSource[] concepts: GraphConcept[] } +/** One configurable engine setting — GET /api/settings `catalog`. */ +export interface SettingDef { + key: string + label: string + help: string + min: number + max: number + default: number +} + +/** GET /api/settings. `settings` is effective, `stored` is what the manifest holds. */ +export interface SettingsPayload { + settings: Record + stored: Record + catalog: SettingDef[] +} + +/** A file inside a layer — GET /api/files. */ +export interface LayerFile { + path: string + name: string + rel: string + ext: string + kind: string // 'text' | 'image' | 'svg' | 'pdf' | 'binary' + markdown: boolean +} + +export interface LayerFiles { + layer: string + kind: string + root: string + fileCount: number + truncated: boolean + files: LayerFile[] +} + +/** GET /api/file — one file's content and metadata. */ +export interface FileContent { + path: string + layer: string + rel: string + ext: string + kind: string + editable: boolean + markdown: boolean + bytes: number + modified: string + text?: string + reason?: string +} + /** The shape build-demo-data.mjs emits and DemoSource imports. */ export interface DemoBundle { graph: GraphSummary diff --git a/apps/console/src/views/Files.test.tsx b/apps/console/src/views/Files.test.tsx new file mode 100644 index 0000000..ca8ba00 --- /dev/null +++ b/apps/console/src/views/Files.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Files } from './Files' + +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), reload: vi.fn(), store: { mode: 'live', sources: [] as unknown[] } })) + +vi.mock('../api', () => ({ apiFetch: mocks.apiFetch })) +vi.mock('../store', () => ({ + useStore: () => ({ mode: mocks.store.mode, sources: mocks.store.sources, reload: mocks.reload }), +})) + +let container: HTMLDivElement +let root: Root + +const FILES = { + layers: [ + { + layer: 'personal', + kind: 'files', + root: '/home/me/notes', + fileCount: 2, + truncated: false, + files: [ + { path: 'personal/meeting.md', name: 'meeting.md', rel: 'meeting.md', ext: '.md', kind: 'text', markdown: true }, + { path: 'personal/logo.png', name: 'logo.png', rel: 'logo.png', ext: '.png', kind: 'image', markdown: false }, + ], + }, + ], +} + +const MEETING = { + path: 'personal/meeting.md', + layer: 'personal', + rel: 'meeting.md', + ext: '.md', + kind: 'text', + editable: true, + markdown: true, + bytes: 42, + modified: '2026-07-01T10:00:00.000Z', + text: '# Meeting\n\n## Decision\n\nShip on **Friday**.\n', +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +function button(label: string): HTMLButtonElement { + const match = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === label) + if (!match) throw new Error(`Button not found: ${label}`) + return match +} + +beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.apiFetch.mockReset() + mocks.reload.mockReset() + mocks.store.mode = 'live' + mocks.store.sources = [] + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(FILES) + if (url.startsWith('/api/file/raw?')) return new Response(new Uint8Array([1]), { status: 200, headers: { 'content-type': 'image/png' } }) + if (url.startsWith('/api/file?')) return json(MEETING) + return json({}) + }) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('Files view', () => { + it('lists each layer with its files and opens a markdown file rendered', async () => { + await act(async () => root.render()) + + expect(container.textContent).toContain('personal') + expect(container.textContent).toContain('meeting.md') + // Rendered by default: the bold survives as markup, not as asterisks. + expect(container.querySelector('.cc-md')?.innerHTML).toContain('Friday') + expect(container.textContent).not.toContain('**Friday**') + }) + + it('switches to the raw Markdown source on demand', async () => { + await act(async () => root.render()) + await act(async () => button('raw').click()) + + const editor = container.querySelector('textarea') + expect(editor?.value).toBe(MEETING.text) + expect(container.querySelector('.cc-md')).toBeNull() + }) + + it('saves an edit and asks the cascade to re-resolve', async () => { + await act(async () => root.render()) + await act(async () => button('raw').click()) + + const editor = container.querySelector('textarea')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(editor, '# Meeting\n\n## Decision\n\nShip on Monday.\n') + editor.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(container.textContent).toContain('unsaved changes') + + mocks.apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/file' && init?.method === 'PUT') return json({ ok: true, modified: '2026-07-02T10:00:00.000Z' }) + if (url === '/api/files') return json(FILES) + return json(MEETING) + }) + await act(async () => button('Save').click()) + + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/file', expect.objectContaining({ method: 'PUT' })) + const saveCall = mocks.apiFetch.mock.calls.find(([url, init]) => url === '/api/file' && init?.method === 'PUT') + expect(JSON.parse(String(saveCall?.[1]?.body))).toMatchObject({ modified: MEETING.modified }) + // An edit changes the resolved cascade — every other view has to re-read it. + expect(mocks.reload).toHaveBeenCalled() + expect(container.textContent).not.toContain('unsaved changes') + }) + + it('surfaces a save failure instead of pretending the edit landed', async () => { + await act(async () => root.render()) + await act(async () => button('raw').click()) + + const editor = container.querySelector('textarea')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(editor, 'changed') + editor.dispatchEvent(new Event('input', { bubbles: true })) + }) + + mocks.apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/file' && init?.method === 'PUT') return json({ error: 'Path escapes its layer root' }, 403) + return json(MEETING) + }) + await act(async () => button('Save').click()) + + expect(container.textContent).toContain('Path escapes its layer root') + expect(container.textContent).toContain('unsaved changes') + }) + + it('loads a non-text preview through the authenticated API', async () => { + let releasePreview: (response: Response) => void = () => {} + mocks.apiFetch.mockImplementation(async (url: string) => { + if (url === '/api/files') return json(FILES) + if (url.startsWith('/api/file/raw?')) { + return new Promise((resolve) => { releasePreview = resolve }) + } + return json({ + path: 'personal/logo.png', layer: 'personal', rel: 'logo.png', ext: '.png', + kind: 'image', editable: false, markdown: false, bytes: 900, modified: '2026-07-01T10:00:00.000Z', + }) + }) + await act(async () => root.render()) + await act(async () => button('logo.png').click()) + + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/file/raw?path=personal%2Flogo.png') + await act(async () => { + // A byte body uses the fetch implementation's own Blob realm when + // Files calls response.blob(), which works on both Node 22 CI and Node 24. + releasePreview(new Response(new Uint8Array([1]), { status: 200, headers: { 'content-type': 'image/png' } })) + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:preview') + expect(container.querySelector('textarea')).toBeNull() + }) + + it('does not discard a dirty file when the user cancels navigation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + await act(async () => root.render()) + await act(async () => button('raw').click()) + const editor = container.querySelector('textarea')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(editor, 'unsaved') + editor.dispatchEvent(new Event('input', { bubbles: true })) + button('logo.png').click() + }) + + expect(window.confirm).toHaveBeenCalled() + expect(container.querySelector('textarea')?.value).toBe('unsaved') + }) + + it('can block app navigation while the current file has unsaved changes', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + await act(async () => root.render()) + await act(async () => button('raw').click()) + const editor = container.querySelector('textarea')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(editor, 'unsaved') + editor.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const allowed = window.dispatchEvent(new Event('contextcake:before-navigate', { cancelable: true })) + expect(allowed).toBe(false) + expect(window.confirm).toHaveBeenCalled() + }) + + it('tells demo users why there is nothing to edit', async () => { + mocks.store.mode = 'demo' + await act(async () => root.render()) + + expect(container.textContent).toContain('live-mode view') + expect(mocks.apiFetch).not.toHaveBeenCalled() + }) + + it('explains an empty state when no source has files on disk', async () => { + mocks.apiFetch.mockImplementation(async () => json({ layers: [] })) + await act(async () => root.render()) + + expect(container.textContent).toContain('No file-backed sources yet') + }) +}) diff --git a/apps/console/src/views/Files.tsx b/apps/console/src/views/Files.tsx new file mode 100644 index 0000000..c9663b2 --- /dev/null +++ b/apps/console/src/views/Files.tsx @@ -0,0 +1,347 @@ +// Files view: the context files behind each source, browsable and editable. +// +// This is the answer to "what can I do while the cascade indexes" — file +// listing is cheap, so this view is useful immediately, even while sources are +// still being read. Markdown opens rendered by default with a Raw tab for the +// actual .md source (frontmatter, OKF heading attrs and all). +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { C, css, MONO } from '../theme' +import { apiFetch } from '../api' +import { Markdown } from '../components/Markdown' +import { useStore } from '../store' +import type { FileContent, LayerFile, LayerFiles } from '../types' + +type Tab = 'rendered' | 'raw' + +async function getJson(path: string): Promise { + const res = await apiFetch(path, { headers: { accept: 'application/json' } }) + const data = await res.json().catch(() => ({}) as { error?: string }) + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + return data as T +} + +function fileLabel(file: LayerFile): string { + return file.rel.includes('/') ? file.rel : file.name +} + +function Empty({ title, detail }: { title: string; detail: string }) { + return ( +
+
+ {title} + {detail} +
+
+ ) +} + +export function Files() { + const { mode, sources, reload } = useStore() + const [layers, setLayers] = useState(null) + const [listError, setListError] = useState(null) + const [selected, setSelected] = useState(null) + const [file, setFile] = useState(null) + const [fileError, setFileError] = useState(null) + const [tab, setTab] = useState('rendered') + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const [savedAt, setSavedAt] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [previewError, setPreviewError] = useState(null) + const [filter, setFilter] = useState('') + const editorRef = useRef(null) + const selectedRef = useRef(selected) + selectedRef.current = selected + + const live = mode === 'live' + const dirty = file?.text !== undefined && draft !== file.text + + // The file list is cheap even mid-index, so it loads on its own and doesn't + // wait for the cascade. It re-runs when the source set changes. + useEffect(() => { + if (!live) return + let cancelled = false + void (async () => { + try { + const data = await getJson<{ layers: LayerFiles[] }>('/api/files') + if (cancelled) return + setLayers(data.layers) + setListError(null) + } catch (e) { + if (!cancelled) setListError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { cancelled = true } + }, [live, sources.length]) + + const allFiles = useMemo( + () => (layers ?? []).flatMap((l) => l.files.map((f) => ({ ...f, layer: l.layer }))), + [layers], + ) + + // Open the first markdown file once, so the view never opens blank. + useEffect(() => { + if (selected || allFiles.length === 0) return + setSelected((allFiles.find((f) => f.markdown) ?? allFiles[0]).path) + }, [allFiles, selected]) + + useEffect(() => { + if (!selected) return + let cancelled = false + void (async () => { + try { + const data = await getJson(`/api/file?path=${encodeURIComponent(selected)}`) + if (cancelled) return + setFile(data) + setDraft(data.text ?? '') + setTab(data.markdown ? 'rendered' : 'raw') + setFileError(null) + setSavedAt(null) + } catch (e) { + if (cancelled) return + setFile(null) + setFileError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { cancelled = true } + }, [selected]) + + // Images and PDFs must be fetched through apiFetch so the desktop bearer is + // present; putting /api/file/raw directly in src would 401 inside the app. + // Object URLs let authenticated responses feed inert image/PDF containers + // without putting the bearer in a URL. + useEffect(() => { + let cancelled = false + let objectUrl: string | null = null + setPreviewUrl(null) + setPreviewError(null) + if (!file || (file.kind !== 'image' && file.kind !== 'pdf')) return undefined + void (async () => { + try { + const res = await apiFetch(`/api/file/raw?path=${encodeURIComponent(file.path)}`) + if (!res.ok) { + const data = await res.json().catch(() => ({}) as { error?: string }) + throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + } + const blob = await res.blob() + if (cancelled) return + objectUrl = URL.createObjectURL(blob) + setPreviewUrl(objectUrl) + } catch (e) { + if (!cancelled) setPreviewError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { + cancelled = true + if (objectUrl) URL.revokeObjectURL(objectUrl) + } + }, [file]) + + useEffect(() => { + if (!dirty) return undefined + const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' } + const guardNavigation = (event: Event) => { + if (!window.confirm(`Discard unsaved changes to ${file?.path ?? 'this file'}?`)) event.preventDefault() + } + window.addEventListener('beforeunload', warn) + window.addEventListener('contextcake:before-navigate', guardNavigation) + return () => { + window.removeEventListener('beforeunload', warn) + window.removeEventListener('contextcake:before-navigate', guardNavigation) + } + }, [dirty, file?.path]) + + const chooseFile = (path: string) => { + if (path === selected) return + if (dirty && !window.confirm(`Discard unsaved changes to ${file?.path ?? 'this file'}?`)) return + setSelected(path) + } + + const save = useCallback(async () => { + if (!file || !dirty || saving) return + const savePath = file.path + const saveDraft = draft + setSaving(true) + setFileError(null) + try { + const res = await apiFetch('/api/file', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: savePath, text: saveDraft, modified: file.modified }), + }) + const data = await res.json().catch(() => ({}) as { error?: string; modified?: string }) + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Server returned ${res.status}`) + if (selectedRef.current === savePath) { + setFile((current) => current?.path === savePath + ? { ...current, text: saveDraft, modified: (data as { modified?: string }).modified ?? current.modified } + : current) + setSavedAt(new Date().toLocaleTimeString()) + } + reload() // the cascade changed — re-resolve so every other view agrees + } catch (e) { + setFileError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + }, [file, dirty, draft, saving, reload]) + + // ⌘S / Ctrl+S saves, the shortcut everyone tries in an editor. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault() + void save() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [save]) + + if (!live) { + return + } + if (listError) { + return + } + if (layers && layers.length === 0) { + return + } + + const visible = (files: LayerFile[]) => { + const q = filter.trim().toLowerCase() + return q ? files.filter((f) => f.path.toLowerCase().includes(q)) : files + } + + return ( +
+ + +
+ {!file && !fileError && } + {fileError && !file && } + {file && ( + <> +
+
+
{file.path}
+
+ {(file.bytes / 1024).toFixed(1)} KB · edited {new Date(file.modified).toLocaleString()} + {savedAt && !dirty && · saved {savedAt}} + {dirty && · unsaved changes} +
+
+ + {file.markdown && ( +
+ {(['rendered', 'raw'] as Tab[]).map((t) => ( + + ))} +
+ )} + + {file.editable && ( + + )} +
+ + {fileError && ( +

{fileError}

+ )} + +
+ {!file.editable && (file.kind === 'image' || file.kind === 'pdf') ? ( + previewError ? ( + + ) : previewUrl ? ( + file.kind === 'image' + ? {file.path} + :