Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
.DS_Store
/dist
/apps/web/dist
/apps/web/dist-mcp
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
notes, topics, tags and stats pages are gone; their content moved into these two.

### Added
- MCP App build with embedded fonts and an SDK bridge to the existing UI API. The same
notes, Review and annotation pages run inside the host, with memory navigation and host theme.
- Web UI served by `distill ui`: home, notes search, note page, topics with timelines, tags,
stats and a problems page. See `README.md`.
- Annotations can be added, edited and deleted on the note page; topics can be renamed and
Expand Down
3 changes: 3 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ Run from the repo root with `pnpm -C apps/web <script>`, or through `mise run ch

With `pnpm dev`, authorize the dev origin by opening the link `distill ui --no-open` prints
with its host replaced by `localhost:5173`.

The Codex plugin also runs this UI as an MCP App. Build outputs and transport behavior are
documented in [docs/web-ui.md](../../docs/web-ui.md#mcp-app-transport).
18 changes: 14 additions & 4 deletions apps/web/biome.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"includes": ["**", "!dist", "!src/api/generated"]
"includes": ["**", "!dist", "!src/api/generated", "!dist-mcp"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"css": {
"parser": { "tailwindDirectives": true }
"parser": {
"tailwindDirectives": true
}
},
"linter": {
"rules": { "preset": "recommended" }
"rules": {
"preset": "recommended"
}
},
"assist": { "actions": { "source": { "organizeImports": "on" } } }
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
13 changes: 13 additions & 0 deletions apps/web/mcp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-transport="mcp">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Distill</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp.ts"></script>
</body>
</html>
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && vite build --mode mcp",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"format": "biome check --write .",
Expand All @@ -15,6 +15,7 @@
"@fontsource/ia-writer-quattro": "5.3.0",
"@fontsource/ibm-plex-serif": "5.3.0",
"@fontsource/lilex": "5.3.0",
"@modelcontextprotocol/ext-apps": "2.0.3",
"@tanstack/react-query": "5.104.0",
"@tanstack/react-router": "1.170.39",
"cmdk": "1.1.1",
Expand All @@ -38,6 +39,7 @@
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.3.1",
"vite-plugin-singlefile": "2.3.3",
"vitest": "5.0.2"
}
}
10 changes: 8 additions & 2 deletions apps/web/src/api/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// The one place the UI talks to `distill ui`. Tests swap in a fake with `setBridge`; a
// future desktop shell would swap in its own transport (spec D7).
// HTTP and MCP transports share the same API paths and response types.

export type Method = "GET" | "POST" | "PUT" | "DELETE";

export interface Bridge {
request<T>(method: Method, path: string, body?: unknown): Promise<T>;
subscribe(refresh: () => void): () => void;
}

/** An API response that was not 2xx. `message` is the server's `error` text. */
Expand All @@ -19,6 +19,12 @@ export class ApiError extends Error {
}

export const httpBridge: Bridge = {
subscribe(refresh) {
const events = new EventSource("/api/events");
events.addEventListener("changed", refresh);
events.addEventListener("open", refresh);
return () => events.close();
},
async request<T>(method: Method, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, {
method,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/api/generated/McpResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type McpResponse = { status: number, data: unknown, };
83 changes: 83 additions & 0 deletions apps/web/src/api/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { App } from "@modelcontextprotocol/ext-apps";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "./bridge";
import { createMcpBridge } from "./mcp";

const result = (status: number, data: unknown) => ({
content: [],
structuredContent: { status, data },
});

function host(response: Awaited<ReturnType<App["callServerTool"]>>) {
return { callServerTool: vi.fn(async () => response) };
}

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

describe("MCP bridge", () => {
it("reads through the app-only tool and preserves API data", async () => {
const app = host(result(200, { notes: 3 }));
await expect(createMcpBridge(app).request("GET", "/stats")).resolves.toEqual({ notes: 3 });
expect(app.callServerTool).toHaveBeenCalledWith({
name: "distill_ui_read",
arguments: { path: "/stats" },
});
});

it("writes through the app-only tool and accepts empty responses", async () => {
const app = host(result(204, null));
await expect(createMcpBridge(app).request("DELETE", "/annotations/1")).resolves.toBeUndefined();
expect(app.callServerTool).toHaveBeenCalledWith({
name: "distill_ui_write",
arguments: { method: "DELETE", path: "/annotations/1" },
});
});

it("preserves API status for authentication and retry decisions", async () => {
const app = host(result(404, { error: "unknown note 01X" }));
const error = await createMcpBridge(app)
.request("GET", "/notes/01X")
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(404);
expect((error as Error).message).toBe("unknown note 01X");
});

it("reports MCP errors and malformed host responses", async () => {
const app = host({
isError: true,
content: [{ type: "text", text: "cannot open vault /missing" }],
});
await expect(createMcpBridge(app).request("GET", "/stats")).rejects.toThrow(
"cannot open vault /missing",
);
app.callServerTool.mockResolvedValue({ content: [] });
await expect(createMcpBridge(app).request("GET", "/stats")).rejects.toThrow(
"Invalid Distill MCP response",
);
});

it("refreshes visible apps and removes timers and listeners on close", () => {
vi.useFakeTimers();
const browser = new EventTarget();
const page = Object.assign(new EventTarget(), { visibilityState: "visible" });
vi.stubGlobal("window", Object.assign(browser, { setInterval, clearInterval }));
vi.stubGlobal("document", page);
const refresh = vi.fn();
const close = createMcpBridge(host(result(200, {}))).subscribe(refresh);
vi.advanceTimersByTime(5000);
expect(refresh).toHaveBeenCalledTimes(1);
page.visibilityState = "hidden";
vi.advanceTimersByTime(5000);
expect(refresh).toHaveBeenCalledTimes(1);
browser.dispatchEvent(new Event("focus"));
expect(refresh).toHaveBeenCalledTimes(2);
close();
vi.advanceTimersByTime(5000);
browser.dispatchEvent(new Event("focus"));
expect(refresh).toHaveBeenCalledTimes(2);
});
});
49 changes: 49 additions & 0 deletions apps/web/src/api/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { App } from "@modelcontextprotocol/ext-apps";
import { ApiError, type Bridge, type Method } from "./bridge";
import type { McpResponse } from "./generated/McpResponse";

export function createMcpBridge(app: Pick<App, "callServerTool">): Bridge {
return {
async request<T>(method: Method, path: string, body?: unknown): Promise<T> {
const result = await app.callServerTool({
name: method === "GET" ? "distill_ui_read" : "distill_ui_write",
arguments:
method === "GET" ? { path } : { method, path, ...(body === undefined ? {} : { body }) },
});
if (result.isError) {
throw new Error(
result.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n"),
);
}
const response = result.structuredContent as McpResponse | undefined;
if (!response || typeof response.status !== "number" || !("data" in response)) {
throw new Error(`Invalid Distill MCP response for ${method} ${path}`);
}
if (response.status >= 400) {
const data = response.data as { error?: unknown } | null;
throw new ApiError(
response.status,
typeof data?.error === "string" ? data.error : `UI API ${response.status} for ${path}`,
);
}
return (response.status === 204 ? undefined : response.data) as T;
},
subscribe(refresh) {
// MCP Apps have no HTTP event stream; refetch while visible and on return to the tab.
const onTick = () => {
if (document.visibilityState === "visible") refresh();
};
const timer = window.setInterval(onTick, 5000);
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", onTick);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", onTick);
};
},
};
}
10 changes: 3 additions & 7 deletions apps/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { useEffect } from "react";
import { ApiError } from "./bridge";
import { ApiError, bridge } from "./bridge";
import { api } from "./client";
import type { NotesQuery } from "./generated/NotesQuery";

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Freshness comes from the server's change events, not from polling.
// The transport owns refresh notifications.
staleTime: Number.POSITIVE_INFINITY,
retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
},
Expand Down Expand Up @@ -49,14 +49,10 @@ export function useVaultEvents(enabled: boolean): void {
if (!enabled) {
return;
}
const events = new EventSource("/api/events");
const refresh = () => {
void client.invalidateQueries({ predicate: (query) => query.queryKey[0] !== "session" });
};
events.addEventListener("changed", refresh);
// After a reconnect (server restarted), anything may have changed.
events.addEventListener("open", refresh);
return () => events.close();
return bridge().subscribe(refresh);
}, [client, enabled]);
}

Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { cx } from "./ui";

type Theme = "system" | "light" | "dark";
const KEY = "distill-theme";
let hostTheme: "light" | "dark" | undefined;

export function setHostTheme(theme: "light" | "dark" | undefined): void {
hostTheme = theme;
applyTheme();
}

function stored(): Theme {
const value = localStorage.getItem(KEY);
Expand All @@ -14,7 +20,10 @@ function stored(): Theme {
export function applyTheme(theme: Theme = stored()): void {
const dark =
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
(theme === "system" &&
(hostTheme
? hostTheme === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches));
document.documentElement.dataset.theme = dark ? "dark" : "light";
}

Expand Down
23 changes: 2 additions & 21 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,3 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { queryClient } from "./api/queries";
import { applyTheme } from "./components/ThemeToggle";
import { router } from "./router";
import "./styles.css";
import { mountApp } from "./render";

applyTheme();

const container = document.getElementById("root");
if (!container) {
throw new Error("index.html has no #root element");
}
createRoot(container).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
mountApp();
27 changes: 27 additions & 0 deletions apps/web/src/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { App } from "@modelcontextprotocol/ext-apps";
import { setBridge } from "./api/bridge";
import { createMcpBridge } from "./api/mcp";
import { setHostTheme } from "./components/ThemeToggle";
import { mountApp } from "./render";

const app = new App(
{ name: "Distill", version: "0.0.1" },
{ availableDisplayModes: ["fullscreen"] },
);
app.onhostcontextchanged = (context) => setHostTheme(context.theme);
app.ontoolresult = () => {
document.title = app.getHostContext()?.toolInfo?.tool.title ?? "Distill";
};

async function start(): Promise<void> {
await app.connect();
setHostTheme(app.getHostContext()?.theme);
document.title = app.getHostContext()?.toolInfo?.tool.title ?? "Distill";
setBridge(createMcpBridge(app));
mountApp();
}

void start().catch((error: unknown) => {
const root = document.getElementById("root");
if (root) root.textContent = `Distill could not connect to the host: ${String(error)}`;
});
Loading
Loading