Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
287 changes: 287 additions & 0 deletions .agents/plans/scratchpad-capture-voice-mobile.md

Large diffs are not rendered by default.

13 changes: 4 additions & 9 deletions apps/daemon/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { CitadelConfig } from "@citadel/config";
import { mergeConfigPatch, saveConfig } from "@citadel/config";
import {
Expand Down Expand Up @@ -39,13 +38,15 @@ import { callDaemonMcpTool, readMcpResource } from "./daemon-mcp-tool.js";
import { registerWorkspaceExtraRoutes } from "./extra-routes.js";
import { registerMcpRoutes } from "./mcp-routes.js";
import { registerNamespaceRoutes } from "./namespace-routes.js";
import { registerQuickCaptureRoute } from "./quick-capture-route.js";
import { deriveReadiness, workspaceAppHookSample } from "./readiness.js";
import { registerRestoreRoutes } from "./restore-routes.js";
import { registerRuntimeUsageRoutes } from "./runtime-usage-routes.js";
import { registerScheduledAgentRoutes } from "./scheduled-agent-routes.js";
import { backfillIfEmpty } from "./scratchpad-history.js";
import { registerScratchpadRoutes } from "./scratchpad-routes.js";
import { scratchpadPath } from "./scratchpad.js";
import { registerSpaFallback } from "./spa-fallback-route.js";
import { startDaemonStatusMonitor } from "./status-monitor-wiring.js";
import { startTerminalReaper } from "./terminal-reaper.js";
import { registerTerminalRoutes } from "./terminal-routes.js";
Expand Down Expand Up @@ -738,14 +739,8 @@ export function createDaemonApp(input: {
req.on("close", () => sseClients.delete(res));
});

const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../web/dist");
if (fs.existsSync(path.join(webDist, "index.html"))) {
app.use(express.static(webDist, { index: false }));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api/") || req.path === "/events") return next();
res.sendFile(path.join(webDist, "index.html"));
});
}
registerQuickCaptureRoute({ app });
registerSpaFallback({ app });

app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof ZodError) {
Expand Down
138 changes: 138 additions & 0 deletions apps/daemon/src/quick-capture-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { QUICK_CAPTURE_SILENCE_TIMEOUT_MS, registerQuickCaptureRoute } from "./quick-capture-route.js";
import { registerSpaFallback } from "./spa-fallback-route.js";

const servers: http.Server[] = [];

afterEach(async () => {
await Promise.all(
servers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
),
);
});

async function startApp(): Promise<string> {
const app = express();
registerQuickCaptureRoute({ app });
// Mimic the daemon's final 404 so we can probe fall-through behavior.
app.use((_req, res) => res.status(404).json({ error: "not_found" }));
const server = http.createServer(app);
servers.push(server);
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected TCP address");
resolve(`http://127.0.0.1:${address.port}`);
});
});
}

describe("GET /quick-capture", () => {
it("returns 200 with text/html", async () => {
const baseUrl = await startApp();
const response = await fetch(`${baseUrl}/quick-capture`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/text\/html/);
expect(response.headers.get("content-type")).toMatch(/charset=utf-8/i);
});

it("includes a <textarea> and references /api/scratchpad/blocks verbatim", async () => {
const baseUrl = await startApp();
const response = await fetch(`${baseUrl}/quick-capture`);
const body = await response.text();
expect(body).toMatch(/<textarea\b/);
expect(body).toContain("/api/scratchpad/blocks");
});

it("includes the iOS-Safari close-fallback message string", async () => {
const baseUrl = await startApp();
const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
expect(body).toContain("Press ⌘W to close");
});

it("does NOT match a sub-path (regression guard against wildcard registration)", async () => {
const baseUrl = await startApp();
const response = await fetch(`${baseUrl}/quick-capture/anything`);
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toMatch(/application\/json/);
});

it("POST /quick-capture is not the HTML page", async () => {
const baseUrl = await startApp();
const response = await fetch(`${baseUrl}/quick-capture`, { method: "POST" });
// Express returns 404 for an unregistered method+path combination via the
// final handler — confirms registration was GET-only.
expect(response.status).toBe(404);
const ct = response.headers.get("content-type") ?? "";
expect(ct).not.toMatch(/text\/html/);
});

it("templates the silence timeout from QUICK_CAPTURE_SILENCE_TIMEOUT_MS into the inline JS", async () => {
const baseUrl = await startApp();
const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
expect(body).toContain(
`setTimeout(function(){ if (rec) try { rec.stop(); } catch(_){} }, ${QUICK_CAPTURE_SILENCE_TIMEOUT_MS})`,
);
expect(body).not.toContain("__SILENCE_TIMEOUT_MS__");
});
});

// Integration: /quick-capture must win over the SPA fallback when both
// routes are registered in the same order as apps/daemon/src/app.ts. A
// regression that swaps the order would have the wildcard SPA route swallow
// /quick-capture and serve the cockpit index.html — this test catches that.
describe("/quick-capture + SPA fallback ordering", () => {
const tmpDirs: string[] = [];

afterEach(() => {
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});

async function start(quickCaptureFirst: boolean): Promise<string> {
const webDist = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-qc-order-"));
tmpDirs.push(webDist);
fs.writeFileSync(path.join(webDist, "index.html"), "<!doctype html><title>cockpit-shell</title>");
const app = express();
if (quickCaptureFirst) {
registerQuickCaptureRoute({ app });
registerSpaFallback({ app, webDist });
} else {
registerSpaFallback({ app, webDist });
registerQuickCaptureRoute({ app });
}
const server = http.createServer(app);
servers.push(server);
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected TCP address");
resolve(`http://127.0.0.1:${address.port}`);
});
});
}

it("correct order: GET /quick-capture returns the quick-capture HTML (not the SPA shell)", async () => {
const baseUrl = await start(true);
const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
expect(body).toContain("<textarea");
expect(body).toContain("/api/scratchpad/blocks");
expect(body).not.toContain("cockpit-shell");
});

it("regression demonstration: SPA-first order WOULD serve the cockpit shell (this is why ordering matters)", async () => {
const baseUrl = await start(false);
const body = await (await fetch(`${baseUrl}/quick-capture`)).text();
// With the wrong order, the wildcard returns the cockpit shell. This test
// documents the failure mode the correct-order test guards against.
expect(body).toContain("cockpit-shell");
});
});
130 changes: 130 additions & 0 deletions apps/daemon/src/quick-capture-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type express from "express";

// Standalone HTML page served outside /api/*. Designed to be opened in a
// Spotlight-style chromeless popup window by external shortcuts
// (scripts/mac-satellite/quick-capture.sh) and post via the existing block
// endpoint — no new daemon write surface, just a convenience UI.
//
// Inlined CSS + JS keep the page bundler-free; the daemon serves it as a
// single string, which means it works even when the cockpit's Vite/SPA bundle
// isn't built (helpful for the daemon-only dev loop). Voice capture reuses
// webkitSpeechRecognition / SpeechRecognition with a silence timeout that
// matches apps/web/src/lib/speech-recognition-controller.ts (kept in sync
// via the constant below — drift on the cockpit side would not silently
// diverge because both surfaces document the value).
export const QUICK_CAPTURE_SILENCE_TIMEOUT_MS = 10_000;

const HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Citadel — Quick capture</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0b101a; color: #e5e9f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
body { display: flex; flex-direction: column; }
.qc-frame { flex: 1 1 auto; display: flex; flex-direction: column; padding: 14px 16px;
gap: 10px; }
.qc-hint { font-size: 11px; color: #6f7c98; letter-spacing: 0.04em; text-transform: uppercase; }
.qc-row { display: flex; gap: 8px; flex: 1 1 auto; min-height: 0; align-items: stretch; }
textarea { flex: 1 1 auto; min-height: 0; resize: none; background: #11182a;
color: inherit; border: 1px solid #1f2a44; border-radius: 6px; padding: 10px 12px;
font-family: inherit; font-size: 14px; line-height: 1.4; outline: none; }
textarea:focus { border-color: #4f8cff; }
button.mic { width: 36px; height: 36px; flex: 0 0 auto; align-self: flex-end;
background: #11182a; color: inherit; border: 1px solid #1f2a44; border-radius: 6px;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
font-size: 16px; }
button.mic.on { background: #4f8cff; color: #fff; border-color: #4f8cff; }
.qc-status { font-size: 12px; color: #97a3c1; min-height: 16px; }
.qc-status.error { color: #f08097; }
.qc-done { font-size: 13px; color: #aab8d8; padding: 12px; text-align: center; }
</style>
</head>
<body>
<div class="qc-frame">
<div class="qc-hint">Quick capture · ⌘+Enter to save · Esc to close</div>
<div class="qc-row">
<textarea id="t" autofocus placeholder="Capture a thought…"></textarea>
<button type="button" class="mic" id="m" aria-label="Voice capture" title="Voice capture">🎙</button>
</div>
<div class="qc-status" id="s" role="status" aria-live="polite"></div>
</div>
<script>
(function(){
var t = document.getElementById('t');
var s = document.getElementById('s');
var m = document.getElementById('m');
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { m.style.display = 'none'; }
var rec = null;
var silenceTimer = null;
function armSilence(){ if(silenceTimer) clearTimeout(silenceTimer);
silenceTimer = setTimeout(function(){ if (rec) try { rec.stop(); } catch(_){} }, __SILENCE_TIMEOUT_MS__); }
function startRec(){
if (!SR) return;
rec = new SR();
rec.continuous = true; rec.interimResults = true;
rec.onresult = function(e){ armSilence();
var last = e.results[e.results.length - 1];
if (!last || !last.isFinal) return;
var piece = (last[0] && last[0].transcript || '').trim();
if (!piece) return;
t.value = t.value.trim().length === 0 ? piece : (t.value.trim() + ' ' + piece);
};
rec.onerror = function(ev){ s.className = 'qc-status error'; s.textContent = 'Voice error: ' + ev.error; stopRec(); };
rec.onend = function(){ m.classList.remove('on'); if(silenceTimer) clearTimeout(silenceTimer); rec = null; };
try { rec.start(); m.classList.add('on'); armSilence(); }
catch(err){ s.className = 'qc-status error'; s.textContent = 'Voice error: ' + (err && err.message || err); rec = null; }
}
function stopRec(){ if(rec) try { rec.stop(); } catch(_){} }
m.addEventListener('click', function(){ if (rec) stopRec(); else startRec(); });

async function submit(){
var text = t.value.trim();
if (!text) { t.focus(); return; }
s.className = 'qc-status'; s.textContent = 'Saving…';
try {
var res = await fetch('/api/scratchpad/blocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
} catch (err) {
s.className = 'qc-status error';
s.textContent = 'Save failed: ' + (err && err.message || err);
t.focus(); return;
}
// Try to close (works in Chrome --app= popups). If still visible ~50ms later
// we're probably in Safari or a regular tab — show a confirmation instead.
window.close();
setTimeout(function(){
if (document.visibilityState === 'visible' && !document.hidden) {
document.body.innerHTML = '<div class="qc-done">Captured. Press ⌘W to close.</div>';
}
}, 60);
}

t.addEventListener('keydown', function(e){
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); return; }
if (e.key === 'Escape') { e.preventDefault(); window.close(); }
});
})();
</script>
</body>
</html>
`;

function renderHtml(): string {
return HTML.replace("__SILENCE_TIMEOUT_MS__", String(QUICK_CAPTURE_SILENCE_TIMEOUT_MS));
}

export function registerQuickCaptureRoute({ app }: { app: express.Express }): void {
const body = renderHtml();
app.get("/quick-capture", (_req, res) => {
res.setHeader("Cache-Control", "no-cache");
res.type("text/html; charset=utf-8").send(body);
});
}
87 changes: 87 additions & 0 deletions apps/daemon/src/spa-fallback-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { registerSpaFallback } from "./spa-fallback-route.js";

const dirs: string[] = [];
const servers: http.Server[] = [];

afterEach(async () => {
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
await Promise.all(
servers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
),
);
});

function makeWebDist(withIndex: boolean) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "citadel-spa-fallback-"));
dirs.push(dir);
if (withIndex) fs.writeFileSync(path.join(dir, "index.html"), "<!doctype html><title>cockpit</title>");
return dir;
}

function startApp(register: (app: express.Express) => void) {
const app = express();
register(app);
// Final JSON error handler mirroring app.ts so a non-matched /api/* request
// surfaces as JSON 404, not as the SPA shell. The fallback must hand off via
// `next()` so this handler runs.
app.use((_req, res) => res.status(404).json({ error: "not_found" }));
const server = http.createServer(app);
servers.push(server);
return new Promise<string>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected TCP address");
resolve(`http://127.0.0.1:${address.port}`);
});
});
}

describe("registerSpaFallback", () => {
it("serves the SPA shell as HTML for an arbitrary non-API GET when index.html exists", async () => {
const webDist = makeWebDist(true);
const baseUrl = await startApp((app) => registerSpaFallback({ app, webDist }));
const response = await fetch(`${baseUrl}/some/cockpit/path`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toMatch(/text\/html/);
const body = await response.text();
expect(body).toContain("cockpit");
});

it("falls through (does NOT return SPA shell) for /api/* so the JSON 404 handler runs", async () => {
const webDist = makeWebDist(true);
const baseUrl = await startApp((app) => registerSpaFallback({ app, webDist }));
const response = await fetch(`${baseUrl}/api/unknown`);
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toMatch(/application\/json/);
const body = (await response.json()) as { error: string };
expect(body.error).toBe("not_found");
});

it("falls through for the /events SSE path", async () => {
const webDist = makeWebDist(true);
const baseUrl = await startApp((app) => registerSpaFallback({ app, webDist }));
const response = await fetch(`${baseUrl}/events`);
expect(response.status).toBe(404);
const body = (await response.json()) as { error: string };
expect(body.error).toBe("not_found");
});

it("registers no routes when index.html is absent", async () => {
const webDist = makeWebDist(false);
const baseUrl = await startApp((app) => registerSpaFallback({ app, webDist }));
const response = await fetch(`${baseUrl}/any/path`);
expect(response.status).toBe(404);
const body = (await response.json()) as { error: string };
expect(body.error).toBe("not_found");
});
});
Loading
Loading