diff --git a/.agents/plans/scratchpad-capture-voice-mobile.md b/.agents/plans/scratchpad-capture-voice-mobile.md new file mode 100644 index 00000000..66cad388 --- /dev/null +++ b/.agents/plans/scratchpad-capture-voice-mobile.md @@ -0,0 +1,287 @@ +Activate the /implement-task skill first. + +# Plan: Scratchpad capture — voice, mobile, Mac satellite (Phase 1) + +## Acceptance Criteria + +Derived verbatim from the source scratchpad block: + +- [ ] AC1 — Voice mode: easy iPhone-first capture into the scratchpad. +- [ ] AC2 — Mobile: scratchpad is the first view that opens. +- [ ] AC3 — Mac satellite: a global shortcut (e.g. `cmd+shift+s`) opens a Spotlight-like scratchpad capture. +- [ ] AC4 — Mac satellite: a separate shortcut shows the native "new workspace" menu so an agent can be started without opening the cockpit. + +Phase-1 operationalised acceptance criteria (what the implementation in this PR delivers): + +- [ ] AC1a — In the cockpit scratchpad composer, a microphone button is visible when the browser exposes a Web Speech API (`SpeechRecognition` or `webkitSpeechRecognition`, including iOS Safari). Tapping it starts recognition; live results append to the composer text. Tapping again, blurring the composer, or 5s of silence stops it. If neither API is exposed, the button is not rendered (no broken UI). +- [ ] AC2a — On viewports matching `(max-width: 820px)`, navigating to the cockpit root with an **otherwise-bare URL** (no search, no hash) immediately routes the user to `/scratchpad`. The redirect runs only when `isBareRootLanding(window.location)` is true (mirroring the existing helper in `apps/web/src/lib/last-route.ts:57`) so any deep-link search/hash combination — including `/?modal=new-workspace` — continues to land on the cockpit. On wider viewports, `/` continues to render the cockpit dashboard unchanged. +- [ ] AC2b — At 375px width (iPhone reference), the scratchpad layout is usable without horizontal scroll: composer is pinned to the bottom respecting `env(safe-area-inset-bottom)`; tap targets (mic, delete, save) are ≥ 36×36 logical px; history sidebar is hidden by default behind a toggle button in the page header. +- [ ] AC3a — The daemon serves a standalone HTML page at `GET /quick-capture` (no React Router, no cockpit chrome): a single autofocused ` + + +
+ + + + +`; + +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); + }); +} diff --git a/apps/daemon/src/spa-fallback-route.test.ts b/apps/daemon/src/spa-fallback-route.test.ts new file mode 100644 index 00000000..8515b7d2 --- /dev/null +++ b/apps/daemon/src/spa-fallback-route.test.ts @@ -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((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"), "cockpit"); + 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((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"); + }); +}); diff --git a/apps/daemon/src/spa-fallback-route.ts b/apps/daemon/src/spa-fallback-route.ts new file mode 100644 index 00000000..ae3c6293 --- /dev/null +++ b/apps/daemon/src/spa-fallback-route.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import express from "express"; + +// Default web/dist location relative to the compiled daemon entry. Resolved +// here so callers don't have to repeat the import.meta.url dance. +export const DEFAULT_WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../web/dist"); + +// Serves the built cockpit SPA shell as the catch-all for non-API GETs so +// TanStack Router can claim any deep path. Extracted from app.ts to keep +// that file under the 800-line gate; behavior is preserved verbatim. +export function registerSpaFallback(input: { app: express.Express; webDist?: string }): void { + const webDist = input.webDist ?? DEFAULT_WEB_DIST; + if (!fs.existsSync(path.join(webDist, "index.html"))) return; + input.app.use(express.static(webDist, { index: false })); + input.app.get("*", (req, res, next) => { + if (req.path.startsWith("/api/") || req.path === "/events") return next(); + res.sendFile(path.join(webDist, "index.html")); + }); +} diff --git a/apps/mac-satellite/README.md b/apps/mac-satellite/README.md new file mode 100644 index 00000000..03a0940d --- /dev/null +++ b/apps/mac-satellite/README.md @@ -0,0 +1,47 @@ +# @citadel/mac-satellite + +Native macOS satellite app built on Electron. Registers two global shortcuts and uses the local Citadel daemon's web surfaces — no new endpoints, no shared state, no codesigning required for personal use. + +| Shortcut | Action | +|---|---| +| `⌘⇧S` | Open a Spotlight-shaped frameless popup loading the daemon's `GET /quick-capture` page. ⌘+Enter saves a new block; Esc closes. | +| `⌘⇧N` | Open the cockpit at `/?modal=new-workspace` in your default browser — the cockpit auto-opens the Create Workspace modal. | + +## Run + +```sh +pnpm --filter @citadel/mac-satellite dev +``` + +That launches Electron and registers the shortcuts. The app runs in the background (no Dock icon, no menu-bar item) and stays alive until you `pkill` it or sign out — that's the whole point of a satellite app. + +## Configuration + +Daemon target defaults to `127.0.0.1:4010` (the long-term systemd daemon). Override via env: + +```sh +CITADEL_HOST=127.0.0.1 CITADEL_PORT=4150 pnpm --filter @citadel/mac-satellite dev +``` + +Worktree-isolated daemons (4110+) are intentionally **not** auto-discovered — a globally-bound shortcut can't infer which worktree is "active", and silently picking one would be a footgun. Set `CITADEL_PORT` explicitly if you want the satellite bound to a specific worktree. + +## Trust model + +Citadel is single-user / localhost-first. The daemon binds `127.0.0.1` by default with CORS allow-all and no per-request authentication. This app does not change that posture — it's a convenience layer over an already-open localhost HTTP surface. If you expose the daemon to a LAN, you own the network gate (SSH tunnel, VPN, auth-adding proxy). + +## Why Electron and not Tauri + +Tauri would ship a smaller binary, but it needs a Rust toolchain in CI and adds platform-specific build complexity. The satellite is ~200 lines of TypeScript and the Electron `globalShortcut` API does exactly what we need. We chose simplicity. Revisit if startup time or memory footprint becomes a problem. + +## Packaging + +This package does not yet produce a `.app` bundle — the dev flow above is sufficient for personal use and CI verification (TypeScript compile + unit tests on the config helpers). Adding `electron-builder` for a signed/notarized `.app` is a follow-up; bring it up when the dev flow isn't enough. + +## What's tested + +- `src/config.ts` — daemon target resolution, env-var precedence, fallback semantics, Spotlight-popup geometry with multi-monitor offsets. See `src/config.test.ts`. +- `src/main.ts` — Electron-internal; not unit-testable as written. Verify manually via `pnpm --filter @citadel/mac-satellite dev`. + +## Shell-script fallback + +If you'd rather not run an Electron process: see [`scripts/mac-satellite/`](../../scripts/mac-satellite/). The scripts wrap the same daemon URLs and bind via Hammerspoon or Shortcuts.app. diff --git a/apps/mac-satellite/package.json b/apps/mac-satellite/package.json new file mode 100644 index 00000000..92160ede --- /dev/null +++ b/apps/mac-satellite/package.json @@ -0,0 +1,17 @@ +{ + "name": "@citadel/mac-satellite", + "version": "0.2.0", + "type": "module", + "private": true, + "description": "macOS satellite app — Spotlight-style quick-capture + new-workspace launcher backed by global shortcuts. Talks to the local Citadel daemon over HTTP.", + "main": "dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "electron .", + "start": "electron ." + }, + "devDependencies": { + "electron": "33.2.1", + "typescript": "^5.8.3" + } +} diff --git a/apps/mac-satellite/src/config.test.ts b/apps/mac-satellite/src/config.test.ts new file mode 100644 index 00000000..71171e66 --- /dev/null +++ b/apps/mac-satellite/src/config.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_HOST, DEFAULT_PORT, centerOnDisplay, resolveDaemonTarget } from "./config.js"; + +describe("resolveDaemonTarget", () => { + it("defaults to 127.0.0.1:4010 (the systemd long-term daemon)", () => { + const target = resolveDaemonTarget({}); + expect(target.host).toBe(DEFAULT_HOST); + expect(target.port).toBe(DEFAULT_PORT); + expect(target.origin).toBe("http://127.0.0.1:4010"); + expect(target.quickCaptureUrl).toBe("http://127.0.0.1:4010/quick-capture"); + expect(target.newWorkspaceUrl).toBe("http://127.0.0.1:4010/?modal=new-workspace"); + expect(target.livenessUrl).toBe("http://127.0.0.1:4010/api/scratchpad"); + }); + + it("honors CITADEL_HOST + CITADEL_PORT env vars", () => { + const target = resolveDaemonTarget({ CITADEL_HOST: "10.0.0.5", CITADEL_PORT: "4150" }); + expect(target.host).toBe("10.0.0.5"); + expect(target.port).toBe(4150); + expect(target.quickCaptureUrl).toBe("http://10.0.0.5:4150/quick-capture"); + }); + + it("falls back to defaults when env values are empty / invalid", () => { + expect(resolveDaemonTarget({ CITADEL_HOST: " ", CITADEL_PORT: "" }).host).toBe(DEFAULT_HOST); + expect(resolveDaemonTarget({ CITADEL_PORT: "not-a-number" }).port).toBe(DEFAULT_PORT); + expect(resolveDaemonTarget({ CITADEL_PORT: "0" }).port).toBe(DEFAULT_PORT); + expect(resolveDaemonTarget({ CITADEL_PORT: "-1" }).port).toBe(DEFAULT_PORT); + }); +}); + +describe("centerOnDisplay", () => { + const display = { workArea: { x: 0, y: 0, width: 1440, height: 900 } }; + + it("centers horizontally on the active display work area", () => { + const { x, width } = centerOnDisplay(display); + expect(width).toBe(640); + expect(x).toBe(Math.round((1440 - 640) / 2)); + }); + + it("places the window in the upper third of the display (Spotlight-style)", () => { + const { y, height } = centerOnDisplay(display); + expect(height).toBe(220); + // 28% from the top of a 900px display = 252. + expect(y).toBe(252); + }); + + it("respects workArea offsets (multi-monitor)", () => { + const offset = { workArea: { x: 1440, y: 200, width: 1920, height: 1080 } }; + const { x, y } = centerOnDisplay(offset); + expect(x).toBe(1440 + Math.round((1920 - 640) / 2)); + expect(y).toBe(200 + Math.round(1080 * 0.28)); + }); +}); diff --git a/apps/mac-satellite/src/config.ts b/apps/mac-satellite/src/config.ts new file mode 100644 index 00000000..7c673501 --- /dev/null +++ b/apps/mac-satellite/src/config.ts @@ -0,0 +1,59 @@ +// Daemon target resolution for the satellite app. +// +// We default to the long-term systemd daemon on 127.0.0.1:4010 (per CLAUDE.md +// and consistent with scripts/mac-satellite/quick-capture.sh). Worktree-isolated +// daemons (4110+) are deliberately NOT auto-discovered — a global shortcut +// bound at the OS level can't know which worktree is "active". Users who want +// a worktree-specific binding set CITADEL_HOST / CITADEL_PORT in the shortcut's +// environment. + +export const DEFAULT_HOST = "127.0.0.1"; +export const DEFAULT_PORT = 4010; + +export type DaemonTarget = { + host: string; + port: number; + origin: string; + quickCaptureUrl: string; + newWorkspaceUrl: string; + livenessUrl: string; +}; + +export function resolveDaemonTarget(env: NodeJS.ProcessEnv = process.env): DaemonTarget { + const host = (env.CITADEL_HOST ?? DEFAULT_HOST).trim() || DEFAULT_HOST; + const portRaw = (env.CITADEL_PORT ?? "").trim(); + const portParsed = Number.parseInt(portRaw, 10); + const port = Number.isFinite(portParsed) && portParsed > 0 ? portParsed : DEFAULT_PORT; + const origin = `http://${host}:${port}`; + return { + host, + port, + origin, + quickCaptureUrl: `${origin}/quick-capture`, + newWorkspaceUrl: `${origin}/?modal=new-workspace`, + livenessUrl: `${origin}/api/scratchpad`, + }; +} + +// Spotlight-shaped popup geometry. Width keeps the textarea ~60-char wide; the +// height accommodates the textarea, mic button, and status line without +// scrolling. Caller centers horizontally on the active display. +export const QUICK_CAPTURE_WINDOW = { + width: 640, + height: 220, +} as const; + +export function centerOnDisplay( + display: { workArea: { x: number; y: number; width: number; height: number } }, + win: { width: number; height: number } = QUICK_CAPTURE_WINDOW, +): { x: number; y: number; width: number; height: number } { + const { workArea } = display; + return { + width: win.width, + height: win.height, + x: Math.round(workArea.x + (workArea.width - win.width) / 2), + // Position slightly above center, Spotlight-style — the eye tracks higher + // than dead-center on a screen, and below-content space is desirable. + y: Math.round(workArea.y + workArea.height * 0.28), + }; +} diff --git a/apps/mac-satellite/src/main.ts b/apps/mac-satellite/src/main.ts new file mode 100644 index 00000000..97f021ed --- /dev/null +++ b/apps/mac-satellite/src/main.ts @@ -0,0 +1,172 @@ +// Citadel macOS satellite — Electron main process. +// +// Registers two global shortcuts: +// ⌘⇧S → open the daemon's /quick-capture page in a frameless, always-on-top +// BrowserWindow sized like Spotlight. Same window is re-shown on +// subsequent presses (toggle-like UX). +// ⌘⇧N → open the cockpit at /?modal=new-workspace in the user's default +// browser. The cockpit auto-opens the Create Workspace modal. +// +// Talks to the local daemon over HTTP — same surface as scripts/mac-satellite/ +// shell helpers, no new endpoints. The shell helpers remain as a no-Electron +// fallback for users who prefer Hammerspoon or Shortcuts.app. + +import { BrowserWindow, Notification, app, globalShortcut, screen, shell } from "electron"; +import { type DaemonTarget, QUICK_CAPTURE_WINDOW, centerOnDisplay, resolveDaemonTarget } from "./config.js"; + +const QUICK_CAPTURE_ACCELERATOR = "CommandOrControl+Shift+S"; +const NEW_WORKSPACE_ACCELERATOR = "CommandOrControl+Shift+N"; + +// Single instance — re-launching the app brings the existing one to the +// foreground rather than spawning a second copy. macOS-standard behavior. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} + +// Run as a background utility (LSUIElement-style) — no Dock icon, no menu bar +// item. The app exists to host the global shortcuts; the user shouldn't see a +// permanent presence in the Dock. +if (process.platform === "darwin" && app.dock) { + app.dock.hide(); +} + +let quickCaptureWindow: BrowserWindow | null = null; + +function liveTarget(): DaemonTarget { + // Re-read on every shortcut so the env override applies if the user + // restarts the app with new CITADEL_HOST / CITADEL_PORT values. + return resolveDaemonTarget(process.env); +} + +function showFallbackNotification(title: string, body: string): void { + if (Notification.isSupported()) { + new Notification({ title, body }).show(); + } else { + console.error(`${title}: ${body}`); + } +} + +async function probeDaemon(target: DaemonTarget): Promise { + // Liveness probe — match the shell helper's behavior (2s max). We don't care + // about the response body; any 2xx/4xx means a daemon is answering. + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + try { + const response = await fetch(target.livenessUrl, { signal: controller.signal }); + return response.ok || response.status >= 400; + } finally { + clearTimeout(timer); + } + } catch { + return false; + } +} + +function buildQuickCaptureWindow(target: DaemonTarget): BrowserWindow { + const primary = screen.getPrimaryDisplay(); + const bounds = centerOnDisplay(primary, QUICK_CAPTURE_WINDOW); + const win = new BrowserWindow({ + ...bounds, + frame: false, + titleBarStyle: "hidden", + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + backgroundColor: "#0b101a", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + // alwaysOnTop with visible-on-all-workspaces gives the Spotlight feel — the + // window appears over fullscreen apps without forcing a Space switch. + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // Closing the page from inside (window.close() after a successful capture) + // emits 'closed'; clear our ref so the next shortcut press builds a fresh + // window with re-resolved daemon target. + win.on("closed", () => { + if (quickCaptureWindow === win) quickCaptureWindow = null; + }); + // Esc closes the popup. The /quick-capture page also calls window.close() + // on Esc, but a top-level shortcut is more reliable across focus states. + win.webContents.on("before-input-event", (event, input) => { + if (input.type === "keyDown" && input.key === "Escape") { + event.preventDefault(); + win.close(); + } + }); + void win.loadURL(target.quickCaptureUrl); + win.once("ready-to-show", () => { + win.show(); + win.focus(); + }); + return win; +} + +async function handleQuickCapture(): Promise { + const target = liveTarget(); + if (!(await probeDaemon(target))) { + showFallbackNotification( + "Citadel Quick Capture", + `Daemon not reachable at ${target.host}:${target.port}. Set CITADEL_HOST / CITADEL_PORT if needed.`, + ); + return; + } + // Toggle: if a popup already exists and is focused, close it instead of + // stacking duplicates. Matches Spotlight's ⌘Space behavior. + if (quickCaptureWindow && !quickCaptureWindow.isDestroyed()) { + if (quickCaptureWindow.isFocused()) { + quickCaptureWindow.close(); + return; + } + quickCaptureWindow.show(); + quickCaptureWindow.focus(); + return; + } + quickCaptureWindow = buildQuickCaptureWindow(target); +} + +async function handleNewWorkspace(): Promise { + const target = liveTarget(); + if (!(await probeDaemon(target))) { + showFallbackNotification("Citadel New Workspace", `Daemon not reachable at ${target.host}:${target.port}.`); + return; + } + // Open in the user's default browser — they likely have a pinned cockpit + // tab. The cockpit's ?modal=new-workspace deeplink auto-opens the modal. + void shell.openExternal(target.newWorkspaceUrl); +} + +app.whenReady().then(() => { + const captureOk = globalShortcut.register(QUICK_CAPTURE_ACCELERATOR, () => { + void handleQuickCapture(); + }); + const workspaceOk = globalShortcut.register(NEW_WORKSPACE_ACCELERATOR, () => { + void handleNewWorkspace(); + }); + if (!captureOk || !workspaceOk) { + // Another app (or a stale instance) holds the shortcut. Surface to the + // user instead of silently failing — they can rebind in System Settings. + showFallbackNotification( + "Citadel Mac Satellite", + "Could not register global shortcuts (⌘⇧S / ⌘⇧N). Another app may have claimed them.", + ); + } +}); + +app.on("will-quit", () => { + globalShortcut.unregisterAll(); +}); + +// macOS apps stay running with no windows. The whole point of this app is +// the global shortcut, so do NOT quit on window-all-closed. +app.on("window-all-closed", () => { + // no-op on macOS — keep running for the shortcut + if (process.platform !== "darwin") app.quit(); +}); diff --git a/apps/mac-satellite/tsconfig.json b/apps/mac-satellite/tsconfig.json new file mode 100644 index 00000000..b0f4e56e --- /dev/null +++ b/apps/mac-satellite/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/index.html b/apps/web/index.html index c880c7fb..c9975da3 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx index 99013e79..74258a19 100644 --- a/apps/web/src/cockpit.tsx +++ b/apps/web/src/cockpit.tsx @@ -9,6 +9,7 @@ import { readinessForWorkspace } from "./cockpit-readiness.js"; import { useWorkspaceCockpitSummary } from "./cockpit-tools.js"; import { CommandPalette } from "./command-palette.js"; import { Inspector } from "./inspector.js"; +import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js"; import { Navigator } from "./navigator.js"; import { RestoreBanner } from "./restore-banner.js"; import { Stage } from "./stage.js"; @@ -47,6 +48,26 @@ export function Cockpit() { } }, [search.workspace, navigate, location.pathname, setActiveWorkspaceId]); + // Deeplink: /?modal=new-workspace opens the existing Create Workspace modal + // on mount, then strips the param so a refresh doesn't re-open it. Used by + // external launchers (scripts/mac-satellite/new-workspace.sh). + useEffect(() => { + if (typeof window === "undefined") return; + if (!shouldOpenNewWorkspaceModal(window.location.search)) return; + setCreateWorkspaceOpen(true); + // The Create Workspace modal lives inside the Navigator column. On mobile + // the column has `display: none` by default (mobileView starts at 'stage'), + // which would hide the modal — switch the mobile view to navigator so the + // modal is actually rendered. On desktop this is a no-op. + setMobileView("navigator"); + consumeNewWorkspaceDeeplink({ + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + history: window.history, + }); + }, []); + const activeWorkspace = useMemo(() => { if (!data?.workspaces.length) return null; return ( diff --git a/apps/web/src/components/voice-capture-button.tsx b/apps/web/src/components/voice-capture-button.tsx new file mode 100644 index 00000000..a3dff58b --- /dev/null +++ b/apps/web/src/components/voice-capture-button.tsx @@ -0,0 +1,54 @@ +import { Mic, MicOff } from "lucide-react"; +import { type CSSProperties, useEffect, useImperativeHandle } from "react"; +import type { Ref } from "react"; +import { useSpeechRecognition } from "../lib/use-speech-recognition.js"; + +export type VoiceCaptureButtonHandle = { + // Imperative stop — parents call this when the host textarea loses focus so + // recognition matches the spec's "blurring stops it" clause. + stop: () => void; +}; + +export type VoiceCaptureButtonProps = { + onTranscript: (text: string) => void; + onError?: (message: string) => void; + className?: string; + style?: CSSProperties; + label?: string; + controlRef?: Ref; +}; + +// Renders a mic toggle that dictates into the host composer via the Web Speech +// API. When the API is unavailable (older Safari, locked-down browsers) the +// component returns null so the host UI gracefully degrades — no broken icon, +// no permission prompt, no console noise. +export function VoiceCaptureButton(props: VoiceCaptureButtonProps) { + const { onTranscript, onError, className, style, label = "Voice capture", controlRef } = props; + const speech = useSpeechRecognition({ onTranscript }); + + // Errors are surfaced via an effect (not render) so an `onError` that updates + // parent state never triggers a render loop. + // biome-ignore lint/correctness/useExhaustiveDependencies: onError is allowed to be unstable; we intentionally key on the error string only. + useEffect(() => { + if (speech.error && onError) onError(speech.error); + }, [speech.error]); + + useImperativeHandle(controlRef, () => ({ stop: () => speech.stop() }), [speech.stop]); + + if (!speech.supported) return null; + + const ariaLabel = speech.listening ? `${label}: stop listening` : `${label}: start listening`; + return ( + + ); +} diff --git a/apps/web/src/lib/append-transcript.test.ts b/apps/web/src/lib/append-transcript.test.ts new file mode 100644 index 00000000..73ec65cd --- /dev/null +++ b/apps/web/src/lib/append-transcript.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { appendTranscript } from "./append-transcript.js"; + +describe("appendTranscript", () => { + it("returns the addition verbatim when existing is empty", () => { + expect(appendTranscript("", "hello")).toBe("hello"); + }); + + it("returns the addition verbatim when existing is whitespace-only", () => { + expect(appendTranscript(" \n ", "hello")).toBe("hello"); + }); + + it("appends with a single space when existing has trimmed content", () => { + expect(appendTranscript("hello", "world")).toBe("hello world"); + }); + + it("trims trailing whitespace from existing before joining", () => { + expect(appendTranscript("hello\n", "world")).toBe("hello world"); + }); +}); diff --git a/apps/web/src/lib/append-transcript.ts b/apps/web/src/lib/append-transcript.ts new file mode 100644 index 00000000..4cd670a9 --- /dev/null +++ b/apps/web/src/lib/append-transcript.ts @@ -0,0 +1,11 @@ +// Append a transcript chunk to existing composer/editor text. Pure function so +// composer, per-block editor, and any future voice surface share the same +// leading-space semantics. The daemon-served quick-capture page has its own +// inline copy (it can't import this) — its semantics are kept in sync by +// convention; see apps/daemon/src/quick-capture-route.ts for the shadow +// implementation. +export function appendTranscript(existing: string, addition: string): string { + const trimmed = existing.trim(); + if (trimmed.length === 0) return addition; + return `${trimmed} ${addition}`; +} diff --git a/apps/web/src/lib/bootstrap-redirect.test.ts b/apps/web/src/lib/bootstrap-redirect.test.ts new file mode 100644 index 00000000..2e578f97 --- /dev/null +++ b/apps/web/src/lib/bootstrap-redirect.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyBootstrapNavigation } from "./bootstrap-redirect.js"; + +function memoryStorage(seed: Record = {}) { + const data = new Map(Object.entries(seed)); + return { + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + removeItem: (k: string) => { + data.delete(k); + }, + }; +} + +function locOf(href: string) { + // href may be "/" | "/foo?bar=1" | "/?modal=x" | "/foo#frag" + const hashIdx = href.indexOf("#"); + const hash = hashIdx >= 0 ? href.slice(hashIdx) : ""; + const noHash = hashIdx >= 0 ? href.slice(0, hashIdx) : href; + const qIdx = noHash.indexOf("?"); + const search = qIdx >= 0 ? noHash.slice(qIdx) : ""; + const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash; + return { pathname, search, hash }; +} + +describe("applyBootstrapNavigation — mobile redirect wins over saved last-route", () => { + it("narrow viewport + bare root + saved /settings → URL becomes /scratchpad (mobile wins)", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage({ "citadel:lastRoute": "/settings" }); + applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: true }); + expect(history.replaceState).toHaveBeenCalledWith({}, "", "/scratchpad"); + expect(history.replaceState).toHaveBeenCalledTimes(1); + }); + + it("wide viewport + bare root + saved /settings → URL becomes /settings (bootstrap wins)", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage({ "citadel:lastRoute": "/settings" }); + applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: false }); + expect(history.replaceState).toHaveBeenCalledWith(null, "", "/settings"); + }); + + it("narrow viewport + /?modal=new-workspace → URL untouched (deeplink wins over mobile default)", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage(); + applyBootstrapNavigation({ location: locOf("/?modal=new-workspace"), history, storage, narrow: true }); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it("narrow viewport + deep path (/settings) → URL untouched", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage(); + applyBootstrapNavigation({ location: locOf("/settings"), history, storage, narrow: true }); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it("wide viewport + bare root + no saved route → URL untouched", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage(); + applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: false }); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it("narrow viewport + bare root + no saved route → URL becomes /scratchpad (primary AC2 case)", () => { + const history = { replaceState: vi.fn() }; + const storage = memoryStorage(); + applyBootstrapNavigation({ location: locOf("/"), history, storage, narrow: true }); + expect(history.replaceState).toHaveBeenCalledWith({}, "", "/scratchpad"); + }); +}); diff --git a/apps/web/src/lib/bootstrap-redirect.ts b/apps/web/src/lib/bootstrap-redirect.ts new file mode 100644 index 00000000..98025453 --- /dev/null +++ b/apps/web/src/lib/bootstrap-redirect.ts @@ -0,0 +1,37 @@ +import { bootstrapLastRoute } from "./last-route.js"; +import { MOBILE_MEDIA_QUERY, mobileScratchpadRedirect } from "./mobile-redirect.js"; + +type StorageLike = Pick; +type HistoryLike = Pick; + +export type BootstrapNavigationInput = { + location: Pick; + history: HistoryLike; + storage?: StorageLike | null; + narrow: boolean; +}; + +// Synchronous pre-router navigation gate. Runs the mobile-scratchpad redirect +// BEFORE bootstrapLastRoute so a mobile user with a persisted desktop route +// (e.g. /settings) still lands on the scratchpad — which is what AC2 promises +// ("scratchpad is the first view that opens on mobile"). On wide viewports the +// mobile rule no-ops and bootstrapLastRoute owns the decision unchanged. +export function applyBootstrapNavigation(input: BootstrapNavigationInput): void { + const { location, history, narrow } = input; + const mobileTarget = mobileScratchpadRedirect(location, narrow); + if (mobileTarget) { + history.replaceState({}, "", mobileTarget); + return; + } + bootstrapLastRoute(location, history, input.storage ?? null); +} + +export function applyBootstrapNavigationFromWindow(): void { + if (typeof window === "undefined") return; + const narrow = window.matchMedia(MOBILE_MEDIA_QUERY).matches; + applyBootstrapNavigation({ + location: window.location, + history: window.history, + narrow, + }); +} diff --git a/apps/web/src/lib/mobile-redirect.test.ts b/apps/web/src/lib/mobile-redirect.test.ts new file mode 100644 index 00000000..734067cd --- /dev/null +++ b/apps/web/src/lib/mobile-redirect.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { mobileScratchpadRedirect } from "./mobile-redirect.js"; + +const loc = (pathname: string, search = "", hash = "") => ({ pathname, search, hash }); + +describe("mobileScratchpadRedirect", () => { + it("redirects to /scratchpad only when the URL is bare AND the viewport is narrow", () => { + expect(mobileScratchpadRedirect(loc("/"), true)).toBe("/scratchpad"); + }); + + it("returns null when the viewport is wide", () => { + expect(mobileScratchpadRedirect(loc("/"), false)).toBeNull(); + }); + + it("returns null when the path is not the bare root", () => { + expect(mobileScratchpadRedirect(loc("/scratchpad"), true)).toBeNull(); + expect(mobileScratchpadRedirect(loc("/settings"), true)).toBeNull(); + expect(mobileScratchpadRedirect(loc("/operations"), true)).toBeNull(); + }); + + it("returns null when the root carries a query string (regression: ?modal=new-workspace must NOT be eaten on mobile)", () => { + expect(mobileScratchpadRedirect(loc("/", "?modal=new-workspace"), true)).toBeNull(); + expect(mobileScratchpadRedirect(loc("/", "?anything=x"), true)).toBeNull(); + }); + + it("returns null when the root carries a hash fragment", () => { + expect(mobileScratchpadRedirect(loc("/", "", "#foo"), true)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/mobile-redirect.ts b/apps/web/src/lib/mobile-redirect.ts new file mode 100644 index 00000000..48c2975c --- /dev/null +++ b/apps/web/src/lib/mobile-redirect.ts @@ -0,0 +1,19 @@ +import { isBareRootLanding } from "./last-route.js"; + +// Match the breakpoint baked into apps/web/src/styles.css and responsive.css — +// keep the value here in sync so the redirect threshold and the mobile CSS +// kick in at the same width. +export const MOBILE_MEDIA_QUERY = "(max-width: 820px)"; + +// Returns the scratchpad path when the user is landing on the bare root on a +// narrow viewport, so the scratchpad becomes the first view on mobile. Returns +// null in every other case — any deep link (different pathname, or root with +// search/hash, e.g. /?modal=new-workspace) wins over the mobile default. +export function mobileScratchpadRedirect( + loc: Pick, + narrow: boolean, +): "/scratchpad" | null { + if (!narrow) return null; + if (!isBareRootLanding(loc)) return null; + return "/scratchpad"; +} diff --git a/apps/web/src/lib/new-workspace-deeplink.test.ts b/apps/web/src/lib/new-workspace-deeplink.test.ts new file mode 100644 index 00000000..cab1e810 --- /dev/null +++ b/apps/web/src/lib/new-workspace-deeplink.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./new-workspace-deeplink.js"; + +describe("shouldOpenNewWorkspaceModal", () => { + it("true for ?modal=new-workspace", () => { + expect(shouldOpenNewWorkspaceModal("?modal=new-workspace")).toBe(true); + }); + + it("false for an empty search string", () => { + expect(shouldOpenNewWorkspaceModal("")).toBe(false); + }); + + it("false for any other modal value", () => { + expect(shouldOpenNewWorkspaceModal("?modal=add-repo")).toBe(false); + expect(shouldOpenNewWorkspaceModal("?modal=")).toBe(false); + }); + + it("false when the param is missing entirely", () => { + expect(shouldOpenNewWorkspaceModal("?source=mobile&workspace=ws_1")).toBe(false); + }); + + it("true when the param is one of several", () => { + expect(shouldOpenNewWorkspaceModal("?source=satellite&modal=new-workspace")).toBe(true); + }); +}); + +describe("consumeNewWorkspaceDeeplink", () => { + it("strips only the modal param via replaceState, preserving other params", () => { + const history = { replaceState: vi.fn() }; + consumeNewWorkspaceDeeplink({ + pathname: "/", + search: "?source=satellite&modal=new-workspace", + hash: "", + history, + }); + expect(history.replaceState).toHaveBeenCalledTimes(1); + const [, , url] = history.replaceState.mock.calls[0] ?? []; + expect(url).toBe("/?source=satellite"); + }); + + it("strips the search entirely when modal was the only param", () => { + const history = { replaceState: vi.fn() }; + consumeNewWorkspaceDeeplink({ + pathname: "/", + search: "?modal=new-workspace", + hash: "", + history, + }); + const [, , url] = history.replaceState.mock.calls[0] ?? []; + expect(url).toBe("/"); + }); + + it("preserves the hash fragment when stripping the modal param", () => { + const history = { replaceState: vi.fn() }; + consumeNewWorkspaceDeeplink({ + pathname: "/dashboard", + search: "?modal=new-workspace", + hash: "#x", + history, + }); + const [, , url] = history.replaceState.mock.calls[0] ?? []; + expect(url).toBe("/dashboard#x"); + }); + + it("does nothing when the param isn't present", () => { + const history = { replaceState: vi.fn() }; + consumeNewWorkspaceDeeplink({ + pathname: "/", + search: "?other=1", + hash: "", + history, + }); + expect(history.replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/new-workspace-deeplink.ts b/apps/web/src/lib/new-workspace-deeplink.ts new file mode 100644 index 00000000..6331c284 --- /dev/null +++ b/apps/web/src/lib/new-workspace-deeplink.ts @@ -0,0 +1,28 @@ +// Deeplink helper for the cockpit's Create Workspace modal. +// +// External launchers (e.g. scripts/mac-satellite/new-workspace.sh) jump +// straight into workspace creation by opening the cockpit at +// /?modal=new-workspace. The Cockpit component checks shouldOpenNewWorkspaceModal +// on mount and, if true, opens the existing modal AND strips the param from +// the URL via consumeNewWorkspaceDeeplink so a page refresh doesn't re-open it. + +export function shouldOpenNewWorkspaceModal(search: string): boolean { + const params = new URLSearchParams(search); + return params.get("modal") === "new-workspace"; +} + +type HistoryLike = Pick; + +export function consumeNewWorkspaceDeeplink(input: { + pathname: string; + search: string; + hash: string; + history: HistoryLike; +}): void { + if (!shouldOpenNewWorkspaceModal(input.search)) return; + const params = new URLSearchParams(input.search); + params.delete("modal"); + const next = params.toString(); + const url = `${input.pathname}${next ? `?${next}` : ""}${input.hash}`; + input.history.replaceState({}, "", url); +} diff --git a/apps/web/src/lib/speech-recognition-controller.test.ts b/apps/web/src/lib/speech-recognition-controller.test.ts new file mode 100644 index 00000000..29b2c27c --- /dev/null +++ b/apps/web/src/lib/speech-recognition-controller.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + SILENCE_TIMEOUT_MS, + type SpeechRecognitionCtor, + createSpeechRecognitionController, + isSpeechRecognitionSupported, +} from "./speech-recognition-controller.js"; + +// Minimal hand-rolled stand-in for the browser's SpeechRecognition. We only +// implement the fields/methods the controller actually touches. +class MockRecognition { + static instances: MockRecognition[] = []; + // Set on the class to inject a synchronous throw from start(); class-level + // (not instance-level) so a beforeEach-style mutation applies to the next + // constructed instance. + static startThrows: unknown = null; + startCalls = 0; + stopCalls = 0; + abortCalls = 0; + continuous = false; + interimResults = false; + lang = ""; + onresult: ((event: { results: ArrayLike & { isFinal: boolean }> }) => void) | null = + null; + onerror: ((event: { error: string }) => void) | null = null; + onend: (() => void) | null = null; + + constructor() { + MockRecognition.instances.push(this); + } + + start(): void { + this.startCalls += 1; + if (MockRecognition.startThrows) throw MockRecognition.startThrows; + } + + stop(): void { + this.stopCalls += 1; + } + + abort(): void { + this.abortCalls += 1; + } +} + +const Ctor = MockRecognition as unknown as SpeechRecognitionCtor; + +function emitResult(rec: MockRecognition, transcript: string, isFinal: boolean) { + rec.onresult?.({ + results: [Object.assign([{ transcript }], { isFinal })], + } as never); +} + +beforeEach(() => { + MockRecognition.instances.length = 0; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + // Scrub the globals the support probe reads. Use `undefined` assignment + // rather than `delete` so biome's noDelete lint stays quiet; the probe + // uses `??` so undefined and missing are equivalent. + (globalThis as { SpeechRecognition?: unknown }).SpeechRecognition = undefined; + (globalThis as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition = undefined; +}); + +describe("isSpeechRecognitionSupported", () => { + it("returns false when neither global is exposed", () => { + expect(isSpeechRecognitionSupported()).toBe(false); + }); + + it("returns true when `webkitSpeechRecognition` is exposed (iOS Safari path)", () => { + (globalThis as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition = MockRecognition; + expect(isSpeechRecognitionSupported()).toBe(true); + }); + + it("returns true when `SpeechRecognition` is exposed", () => { + (globalThis as { SpeechRecognition?: unknown }).SpeechRecognition = MockRecognition; + expect(isSpeechRecognitionSupported()).toBe(true); + }); +}); + +describe("createSpeechRecognitionController", () => { + it("start() invokes recognition.start exactly once and reports listening:true", () => { + const onState = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor, onStateChange: onState }); + controller.start(); + const [rec] = MockRecognition.instances; + expect(rec?.startCalls).toBe(1); + expect(onState).toHaveBeenCalledWith(true); + }); + + it("calling start() while already listening is a no-op", () => { + const controller = createSpeechRecognitionController({ Ctor }); + controller.start(); + controller.start(); + expect(MockRecognition.instances).toHaveLength(1); + expect(MockRecognition.instances[0]?.startCalls).toBe(1); + }); + + it("forwards a final transcript through onTranscript", () => { + const onTranscript = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor, onTranscript }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + emitResult(rec, "hello world", true); + expect(onTranscript).toHaveBeenCalledWith("hello world"); + }); + + it("after SILENCE_TIMEOUT_MS without results, stop() is called once", () => { + const onState = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor, onStateChange: onState }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + vi.advanceTimersByTime(SILENCE_TIMEOUT_MS); + expect(rec.stopCalls).toBe(1); + }); + + it("an interim result resets the silence timer", () => { + const controller = createSpeechRecognitionController({ Ctor }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + vi.advanceTimersByTime(SILENCE_TIMEOUT_MS - 100); + emitResult(rec, "ongoing", false); + vi.advanceTimersByTime(SILENCE_TIMEOUT_MS - 100); + expect(rec.stopCalls).toBe(0); + vi.advanceTimersByTime(200); + expect(rec.stopCalls).toBe(1); + }); + + it("when start() throws synchronously (iOS gesture-context failure), the error is surfaced via onError and listening stays false", () => { + const onError = vi.fn(); + const onState = vi.fn(); + MockRecognition.startThrows = new Error("InvalidStateError"); + try { + const controller = createSpeechRecognitionController({ Ctor, onError, onStateChange: onState }); + controller.start(); + expect(onError).toHaveBeenCalledWith(expect.stringContaining("InvalidStateError")); + expect(onState).not.toHaveBeenCalledWith(true); + } finally { + MockRecognition.startThrows = null; + } + }); + + it("propagates recognition.onerror through onError and stops listening", () => { + const onError = vi.fn(); + const onState = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor, onError, onStateChange: onState }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + rec.onerror?.({ error: "no-speech" }); + expect(onError).toHaveBeenCalledWith("no-speech"); + expect(onState).toHaveBeenLastCalledWith(false); + }); + + it("dispose() aborts an in-flight recognition and clears the silence timer", () => { + const controller = createSpeechRecognitionController({ Ctor }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + controller.dispose(); + expect(rec.abortCalls + rec.stopCalls).toBeGreaterThan(0); + // Timer should no longer fire after dispose; advancing time must not throw or call stop again. + const stopsBefore = rec.stopCalls; + vi.advanceTimersByTime(SILENCE_TIMEOUT_MS * 2); + expect(rec.stopCalls).toBe(stopsBefore); + }); + + it("dispose() detaches handlers so a late onend from abort() cannot fire onStateChange after dispose", () => { + const onState = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor, onStateChange: onState }); + controller.start(); + const rec = MockRecognition.instances[0]; + if (!rec) throw new Error("expected a mock recognition instance"); + controller.dispose(); + // Real browsers fire onend asynchronously after abort(). Simulate that: + // call the handler the controller had attached, if it didn't null it. + rec.onend?.(); + rec.onerror?.({ error: "aborted" }); + // After dispose, onStateChange must NOT have been called with `false` + // by a late handler — only the explicit dispose-time absence matters. + expect(onState).not.toHaveBeenCalledWith(false); + }); + + it("returns null when the API is unsupported and start() is a no-op", () => { + const onState = vi.fn(); + const controller = createSpeechRecognitionController({ Ctor: undefined, onStateChange: onState }); + controller.start(); + expect(MockRecognition.instances).toHaveLength(0); + expect(onState).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/speech-recognition-controller.ts b/apps/web/src/lib/speech-recognition-controller.ts new file mode 100644 index 00000000..035e7582 --- /dev/null +++ b/apps/web/src/lib/speech-recognition-controller.ts @@ -0,0 +1,141 @@ +// Pure (non-React) wrapper around the Web Speech API. Owns the silence +// timer and the supported/listening state machine so the React hook in +// use-speech-recognition.ts stays a thin adapter and the logic is +// unit-testable without RTL. + +// 10s — long enough to cover thinking pauses during multi-sentence dictation; +// short enough that the mic doesn't run forever if the user walks away. +export const SILENCE_TIMEOUT_MS = 10_000; + +// We only depend on the SpeechRecognition constructor's *shape* — the runtime +// objects are browser-provided. Typed loosely on purpose to avoid pulling in +// `lib.dom`'s experimental SpeechRecognition typings that drift across TS +// versions. +type RecognitionEventResultItem = { transcript: string }; +type RecognitionEventResult = ArrayLike & { isFinal: boolean }; +type RecognitionEvent = { results: ArrayLike }; +type ErrorEvent = { error: string }; + +type RecognitionInstance = { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((event: RecognitionEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; + abort(): void; +}; + +export type SpeechRecognitionCtor = new () => RecognitionInstance; + +export type SpeechRecognitionController = { + start(): void; + stop(): void; + dispose(): void; +}; + +export type SpeechRecognitionControllerInput = { + Ctor: SpeechRecognitionCtor | undefined; + onTranscript?: (text: string) => void; + onError?: (message: string) => void; + onStateChange?: (listening: boolean) => void; + silenceTimeoutMs?: number; +}; + +export function isSpeechRecognitionSupported(): boolean { + return resolveCtor() !== undefined; +} + +export function resolveCtor(): SpeechRecognitionCtor | undefined { + const g = globalThis as { + SpeechRecognition?: SpeechRecognitionCtor; + webkitSpeechRecognition?: SpeechRecognitionCtor; + }; + return g.SpeechRecognition ?? g.webkitSpeechRecognition; +} + +export function createSpeechRecognitionController( + input: SpeechRecognitionControllerInput, +): SpeechRecognitionController { + const { Ctor, onTranscript, onError, onStateChange } = input; + const silenceMs = input.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS; + let active: RecognitionInstance | null = null; + let silenceTimer: ReturnType | null = null; + let disposed = false; + + function clearSilenceTimer() { + if (silenceTimer) { + clearTimeout(silenceTimer); + silenceTimer = null; + } + } + + function armSilenceTimer() { + clearSilenceTimer(); + silenceTimer = setTimeout(() => { + active?.stop(); + }, silenceMs); + } + + function stopAndReport() { + clearSilenceTimer(); + active = null; + onStateChange?.(false); + } + + return { + start() { + if (disposed || active || !Ctor) return; + const recognition = new Ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.onresult = (event: RecognitionEvent) => { + armSilenceTimer(); + const last = event.results[event.results.length - 1]; + if (!last || !last.isFinal) return; + const piece = last[0]?.transcript ?? ""; + if (piece) onTranscript?.(piece.trim()); + }; + recognition.onerror = (event: ErrorEvent) => { + onError?.(event.error); + stopAndReport(); + }; + recognition.onend = () => { + stopAndReport(); + }; + try { + recognition.start(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onError?.(message); + return; + } + active = recognition; + armSilenceTimer(); + onStateChange?.(true); + }, + stop() { + active?.stop(); + }, + dispose() { + disposed = true; + clearSilenceTimer(); + if (active) { + // Null the handlers BEFORE abort() so the async onend/onerror events + // browsers emit during teardown cannot reach stopAndReport — otherwise + // onStateChange would fire on a disposed controller. + active.onresult = null; + active.onerror = null; + active.onend = null; + try { + active.abort(); + } catch { + // ignore + } + active = null; + } + }, + }; +} diff --git a/apps/web/src/lib/use-speech-recognition.ts b/apps/web/src/lib/use-speech-recognition.ts new file mode 100644 index 00000000..5f59d739 --- /dev/null +++ b/apps/web/src/lib/use-speech-recognition.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type SpeechRecognitionController, + createSpeechRecognitionController, + isSpeechRecognitionSupported, + resolveCtor, +} from "./speech-recognition-controller.js"; + +export type UseSpeechRecognitionInput = { + onTranscript: (text: string) => void; +}; + +export type UseSpeechRecognition = { + supported: boolean; + listening: boolean; + error: string | null; + start: () => void; + stop: () => void; +}; + +// Thin React adapter around createSpeechRecognitionController. All non-trivial +// logic lives in the controller so it can be unit-tested without RTL. +export function useSpeechRecognition(input: UseSpeechRecognitionInput): UseSpeechRecognition { + const { onTranscript } = input; + const supported = useMemo(() => isSpeechRecognitionSupported(), []); + const [listening, setListening] = useState(false); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + const onTranscriptRef = useRef(onTranscript); + + useEffect(() => { + onTranscriptRef.current = onTranscript; + }, [onTranscript]); + + useEffect(() => { + if (!supported) return; + const controller = createSpeechRecognitionController({ + Ctor: resolveCtor(), + onTranscript: (text) => onTranscriptRef.current(text), + onError: (message) => setError(message), + onStateChange: setListening, + }); + controllerRef.current = controller; + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, [supported]); + + const start = useCallback(() => { + setError(null); + controllerRef.current?.start(); + }, []); + const stop = useCallback(() => controllerRef.current?.stop(), []); + + return { supported, listening, error, start, stop }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c293b990..ebbee2a9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,7 +4,8 @@ import { useEffect } from "react"; import { createRoot } from "react-dom/client"; import { queryClient } from "./api.js"; import { Cockpit } from "./cockpit.js"; -import { bootstrapLastRoute, clearLastRoute, saveLastRoute } from "./lib/last-route.js"; +import { applyBootstrapNavigationFromWindow } from "./lib/bootstrap-redirect.js"; +import { clearLastRoute, saveLastRoute } from "./lib/last-route.js"; import { DashboardView } from "./routes/dashboard.js"; import { HistoryView } from "./routes/history.js"; import { OnboardingView } from "./routes/onboarding.js"; @@ -130,12 +131,12 @@ function NotFoundView() { ); } -// Restore the last visited route BEFORE the router boots so it picks the -// correct initial location off the URL bar. The decision logic lives in -// bootstrapLastRoute so it can be unit-tested independently. -if (typeof window !== "undefined") { - bootstrapLastRoute(window.location, window.history); -} +// Decide the initial URL BEFORE the router boots so it picks the right +// location off the URL bar. applyBootstrapNavigationFromWindow first checks +// the mobile-default rule (narrow viewport + bare root → /scratchpad) and, +// when that doesn't fire, falls through to bootstrapLastRoute to restore a +// persisted desktop route. Each rule is unit-tested in lib/. +applyBootstrapNavigationFromWindow(); const router = createRouter({ routeTree: rootRoute.addChildren([ diff --git a/apps/web/src/routes/scratchpad.tsx b/apps/web/src/routes/scratchpad.tsx index 22f24edf..86bce2fa 100644 --- a/apps/web/src/routes/scratchpad.tsx +++ b/apps/web/src/routes/scratchpad.tsx @@ -1,8 +1,10 @@ import type { ScratchpadBlockSummary, ScratchpadHistorySummary, ScratchpadSnapshot } from "@citadel/contracts"; import { Link } from "@tanstack/react-router"; -import { ArrowLeft, Trash2, X } from "lucide-react"; +import { ArrowLeft, Clock, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "../api.js"; +import { VoiceCaptureButton, type VoiceCaptureButtonHandle } from "../components/voice-capture-button.js"; +import { appendTranscript } from "../lib/append-transcript.js"; import { sideBySideDiff } from "./scratchpad-diff.js"; import { formatBytes, pillLabel, pillSlug } from "./scratchpad-helpers.js"; import { renderBlockMarkdown } from "./scratchpad-markdown.js"; @@ -30,6 +32,9 @@ export function ScratchpadView() { const [restoring, setRestoring] = useState(false); const [composerError, setComposerError] = useState(null); const [undo, setUndo] = useState(null); + // On narrow viewports the history sidebar is hidden behind this toggle. + // Desktop CSS always shows the sidebar regardless of this flag. + const [historyOpen, setHistoryOpen] = useState(false); const listRef = useRef(null); const composerRef = useRef(null); @@ -270,7 +275,10 @@ export function ScratchpadView() { [submitComposer], ); + const composerMicRef = useRef(null); const onComposerBlur = useCallback(() => { + // Spec B.2 Scratchpad #9: voice capture stops on textarea blur. + composerMicRef.current?.stop(); if (composer.trim().length > 0) void submitComposer(composer); }, [composer, submitComposer]); @@ -425,6 +433,17 @@ export function ScratchpadView() { {renderStatus(updatedAt)} +
@@ -460,23 +479,29 @@ export function ScratchpadView() { {composerError}

) : null} -