From 8cadd6cd4afd07eb21ba97ed86c863d488cda46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 21:23:14 +0000 Subject: [PATCH 01/13] docs(specs): add quick-capture, mobile-first scratchpad, voice + Mac satellite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec updates landing first per the implementation plan at .agents/plans/scratchpad-capture-voice-mobile.md: - B.2: scratchpad voice mic affordance; mobile-default route to /scratchpad on narrow viewports; mobile-tightened layout (composer safe-area pinning, ≥36px tap targets, history sidebar behind toggle). - B.7: rename "per-workspace scratchpad.md" to the actual global scratchpad at config.dataDir/scratchpad.md; document the new GET /quick-capture daemon-served HTML page that posts to the existing block endpoint; add a Security/trust-model subsection naming the daemon's localhost-trusted, CORS-open posture. - B.1: document /?modal=new-workspace deeplink for external launchers. - C: note that scripts/mac-satellite/ is macOS-only and outside make check. --- .../plans/scratchpad-capture-voice-mobile.md | 285 ++++++++++++++++++ specs/B.1-repositories-workspaces.md | 1 + specs/B.2-ade-cockpit.md | 5 +- specs/B.7-operations-activity-mcp.md | 8 +- specs/C-technical-stack.md | 1 + 5 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 .agents/plans/scratchpad-capture-voice-mobile.md diff --git a/.agents/plans/scratchpad-capture-voice-mobile.md b/.agents/plans/scratchpad-capture-voice-mobile.md new file mode 100644 index 00000000..dde318c9 --- /dev/null +++ b/.agents/plans/scratchpad-capture-voice-mobile.md @@ -0,0 +1,285 @@ +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 ` + + +
+ + + + +`; + +export function registerQuickCaptureRoute({ app }: { app: express.Express }): void { + app.get("/quick-capture", (_req, res) => { + res.setHeader("Cache-Control", "no-cache"); + res.type("text/html; charset=utf-8").send(HTML); + }); +} From 6eb95fcd1b3448141d402738db25fd8c070d4422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 21:37:32 +0000 Subject: [PATCH 06/13] feat(cockpit): deeplink /?modal=new-workspace opens Create Workspace modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External launchers (the Mac satellite new-workspace shortcut) jump straight into workspace creation by opening the cockpit at this URL. The Cockpit component checks shouldOpenNewWorkspaceModal on mount, opens the existing modal, and consumeNewWorkspaceDeeplink strips the param via history.replaceState — preserving other params and the hash — so a page refresh doesn't re-open it. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/cockpit.tsx | 16 ++++ .../src/lib/new-workspace-deeplink.test.ts | 75 +++++++++++++++++++ apps/web/src/lib/new-workspace-deeplink.ts | 28 +++++++ 3 files changed, 119 insertions(+) create mode 100644 apps/web/src/lib/new-workspace-deeplink.test.ts create mode 100644 apps/web/src/lib/new-workspace-deeplink.ts diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx index 237e83db..e84ed071 100644 --- a/apps/web/src/cockpit.tsx +++ b/apps/web/src/cockpit.tsx @@ -14,6 +14,7 @@ import { Stage } from "./stage.js"; import { UsageIndicator } from "./usage-indicator.js"; import { startColumnDrag, useCockpitLayout } from "./use-cockpit-layout.js"; import { useResolvedTheme } from "./use-resolved-theme.js"; +import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js"; import { prToneFor } from "./workspace-card.js"; const STORAGE_LAST_WORKSPACE = "citadel.last-workspace"; @@ -46,6 +47,21 @@ 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); + 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/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); +} From 2676f60a5e763e0e4abd93c29f5320be4aa9390b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 21:38:54 +0000 Subject: [PATCH 07/13] feat(mac-satellite): Spotlight-style global-shortcut launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thin POSIX-shell helpers that wrap the daemon's web surfaces into global-shortcut targets on macOS: - quick-capture.sh opens GET /quick-capture in a Chrome --app= popup (Safari fallback) sized like Spotlight. Liveness-probes the daemon first; surfaces a macOS notification if it's not reachable. - new-workspace.sh opens /?modal=new-workspace in the default browser, which the cockpit recognises as a deeplink to the Create Workspace modal. Both default to the systemd long-term daemon at 127.0.0.1:4010, overridable via CITADEL_HOST/CITADEL_PORT. Worktree daemons (4110+) are deliberately NOT auto-discovered — a global shortcut can't infer which worktree is active. README documents Hammerspoon (recommended, silent) and Shortcuts.app (fallback, banner) bindings plus the trust model (daemon is single-user localhost-trusted; cross-LAN exposure is operator-responsibility). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/mac-satellite/README.md | 76 ++++++++++++++++++++++++++ scripts/mac-satellite/new-workspace.sh | 28 ++++++++++ scripts/mac-satellite/quick-capture.sh | 61 +++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 scripts/mac-satellite/README.md create mode 100755 scripts/mac-satellite/new-workspace.sh create mode 100755 scripts/mac-satellite/quick-capture.sh diff --git a/scripts/mac-satellite/README.md b/scripts/mac-satellite/README.md new file mode 100644 index 00000000..f6f58330 --- /dev/null +++ b/scripts/mac-satellite/README.md @@ -0,0 +1,76 @@ +# Citadel — Mac satellite shortcuts + +Two tiny shell helpers that turn the local Citadel daemon's web surfaces into +Spotlight-style global-shortcut targets. Both target the long-term systemd +daemon on `127.0.0.1:4010` by default. + +| Script | What it does | Suggested shortcut | +|---|---|---| +| `quick-capture.sh` | Opens `GET /quick-capture` in a chromeless 640×220 popup. Type a thought, mic-dictate it, ⌘+Enter to save into the scratchpad as a new block, Esc/⌘W to dismiss. | `⌘+⇧+S` | +| `new-workspace.sh` | Opens `/?modal=new-workspace` in the user's default browser. Cockpit auto-opens the Create Workspace modal. | `⌘+⇧+N` | + +> These scripts are intentionally **not** wired into `make check` — they +> depend on macOS-only tooling (`open`, `osascript`, AppleScript, Chrome +> `--app=`) that cannot run in Linux CI. + +## Prerequisite + +The Citadel daemon must be running and reachable. The default target is the +long-term systemd daemon on `127.0.0.1:4010` (see `make install`). + +## Trust model + +Citadel is a **single-user, local-first** tool. The daemon binds `127.0.0.1` +by default with CORS allow-all and no per-request authentication — +`POST /api/scratchpad/blocks` (which the quick-capture page submits to) is +unauthenticated by design. These scripts do not change that posture; they're +a convenience layer over an already-open localhost surface. If you've +configured the daemon to bind a LAN address or you tunnel/forward the port, +you own the corresponding network gate (SSH tunnel, VPN, auth-adding proxy). + +## Worktrees + +The shortcuts target **one** daemon — the long-term systemd one. Worktree +daemons (port `4110–4209` per CLAUDE.md) are deliberately NOT auto-discovered: +a global shortcut bound at the OS level can't know which worktree is "active", +and silently picking one would be a footgun. If you want a shortcut bound to +a specific worktree, set `CITADEL_PORT` (and optionally `CITADEL_HOST`) in +the shortcut's environment — Hammerspoon and Shortcuts.app both support env +overrides per binding. + +## Wiring the shortcut — Hammerspoon (recommended) + +[Hammerspoon](https://www.hammerspoon.org/) is a free macOS automation tool. +After installing it and granting Accessibility permission, drop this into +`~/.hammerspoon/init.lua`: + +```lua +local function run(cmd) + return function() hs.execute(cmd, true) end +end + +local repo = os.getenv("HOME") .. "/path/to/citadel" + +-- ⌘ + Shift + S → quick-capture popup +hs.hotkey.bind({"cmd", "shift"}, "s", run(repo .. "/scripts/mac-satellite/quick-capture.sh")) + +-- ⌘ + Shift + N → new-workspace +hs.hotkey.bind({"cmd", "shift"}, "n", run(repo .. "/scripts/mac-satellite/new-workspace.sh")) +``` + +Then `Reload Config` from Hammerspoon's menu bar icon. Done. + +## Wiring the shortcut — Shortcuts.app (fallback) + +If you'd rather not install Hammerspoon: + +1. Open **Shortcuts.app** → ➕ to create a new shortcut. +2. Add a **Run Shell Script** action. Set the script to the absolute path of + `quick-capture.sh`. Set Shell to `/bin/sh`. +3. Name the shortcut `Citadel Quick Capture`. +4. Click the ⓘ (info) panel → **Add Keyboard Shortcut** → press `⌘+⇧+S`. +5. Repeat for `new-workspace.sh` with `⌘+⇧+N`. + +Shortcuts.app shows a brief macOS-style banner when the shortcut runs, which +is harmless but visible. Hammerspoon is silent — that's why it's the +recommendation. diff --git a/scripts/mac-satellite/new-workspace.sh b/scripts/mac-satellite/new-workspace.sh new file mode 100755 index 00000000..656af39f --- /dev/null +++ b/scripts/mac-satellite/new-workspace.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Citadel new-workspace launcher (macOS). +# +# Opens the cockpit at /?modal=new-workspace in the user's default browser. +# The cockpit recognises the deeplink on mount, auto-opens the existing +# Create Workspace modal, and strips the param from the URL — so the user +# starts a new workspace without navigating through the cockpit shell. +# +# Daemon target: the long-term systemd Citadel daemon at 127.0.0.1:4010 +# (overridable via CITADEL_HOST / CITADEL_PORT). See quick-capture.sh for +# why worktree daemons aren't auto-discovered. + +set -eu + +CITADEL_HOST="${CITADEL_HOST:-127.0.0.1}" +CITADEL_PORT="${CITADEL_PORT:-4010}" +URL="http://${CITADEL_HOST}:${CITADEL_PORT}/?modal=new-workspace" + +if ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/state" > /dev/null 2>&1 \ + && ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/scratchpad" > /dev/null 2>&1; then + osascript -e "display notification \"Citadel daemon not reachable at ${CITADEL_HOST}:${CITADEL_PORT}\" with title \"New workspace\"" + echo "citadel new-workspace: daemon not reachable at ${URL}" >&2 + exit 1 +fi + +# `open` opens in the user's default browser. Intentionally NOT a chromeless +# popup — the user wants to land in their pinned cockpit tab if they have one. +open "$URL" diff --git a/scripts/mac-satellite/quick-capture.sh b/scripts/mac-satellite/quick-capture.sh new file mode 100755 index 00000000..1b15611b --- /dev/null +++ b/scripts/mac-satellite/quick-capture.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Citadel quick-capture launcher (macOS). +# +# Opens the daemon's /quick-capture page as a Spotlight-shaped chromeless +# popup so the user can dictate or type a thought and ⌘+Enter it into the +# global scratchpad. Bind to a global shortcut (e.g. cmd+shift+s) via +# Hammerspoon or Shortcuts.app — see README.md in this directory. +# +# Daemon target: the long-term systemd Citadel daemon at 127.0.0.1:4010. +# Worktree-isolated daemons (4110+ per CLAUDE.md) are deliberately NOT +# auto-discovered — a global shortcut cannot infer which worktree is "active". +# Override with CITADEL_HOST / CITADEL_PORT env vars if you want the shortcut +# bound to a specific worktree. + +set -eu + +CITADEL_HOST="${CITADEL_HOST:-127.0.0.1}" +CITADEL_PORT="${CITADEL_PORT:-4010}" +URL="http://${CITADEL_HOST}:${CITADEL_PORT}/quick-capture" + +# Liveness probe — fail fast with a useful message rather than opening a blank +# tab. --max-time keeps us snappy for the global-shortcut UX. +if ! curl -fsS --max-time 2 "http://${CITADEL_HOST}:${CITADEL_PORT}/api/scratchpad" > /dev/null 2>&1; then + osascript -e "display notification \"Citadel daemon not reachable at ${CITADEL_HOST}:${CITADEL_PORT}\" with title \"Quick capture\"" + echo "citadel quick-capture: daemon not reachable at ${URL}" >&2 + exit 1 +fi + +# Prefer Chrome --app= mode so the page renders chromeless and window.close() +# actually works after a successful capture. Fall back to Safari for users +# without Chrome (the page will show its "Press ⌘W to close" hint there). +CHROME="" +for candidate in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" +do + if [ -x "$candidate" ]; then + CHROME="$candidate" + break + fi +done + +if [ -n "$CHROME" ]; then + # --user-data-dir keeps the satellite popup in its own profile so it doesn't + # share state with the user's main browser. --window-size sizes the popup + # roughly Spotlight-shaped; --window-position aims at the center of a + # 1440-wide screen — the user can move it once and Chrome remembers. + PROFILE_DIR="${HOME}/.cache/citadel-mac-satellite" + mkdir -p "$PROFILE_DIR" + "$CHROME" \ + --user-data-dir="$PROFILE_DIR" \ + --app="$URL" \ + --window-size=640,220 \ + --window-position=400,300 \ + >/dev/null 2>&1 & +else + # Safari fallback — opens in a normal window; user closes with ⌘W after the + # page's "Captured. Press ⌘W to close." confirmation. + open -a Safari "$URL" +fi From f390551f182c798cb65f2066be2f5f29782f112e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 21:39:51 +0000 Subject: [PATCH 08/13] test(e2e): mobile scratchpad redirect, deeplink, and quick-capture flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new Playwright specs: - scratchpad-mobile.spec.ts — on narrow viewports (≤820px), bare root redirects to /scratchpad; History toggle is in the header; the version history sidebar is hidden by default and visible after toggling. - scratchpad-mobile-deeplink.spec.ts — BLOCKER-2 regression guard: /?modal=new-workspace on a narrow viewport stays at / (no mobile redirect) and auto-opens the Create Workspace modal. - quick-capture.spec.ts — hits the daemon-served /quick-capture page, fills the textarea, ⌘+Enter, and asserts via the API that a new scratchpad block surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/quick-capture.spec.ts | 41 ++++++++++++++++++++++++++ e2e/scratchpad-mobile-deeplink.spec.ts | 19 ++++++++++++ e2e/scratchpad-mobile.spec.ts | 24 +++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 e2e/quick-capture.spec.ts create mode 100644 e2e/scratchpad-mobile-deeplink.spec.ts create mode 100644 e2e/scratchpad-mobile.spec.ts diff --git a/e2e/quick-capture.spec.ts b/e2e/quick-capture.spec.ts new file mode 100644 index 00000000..5839b098 --- /dev/null +++ b/e2e/quick-capture.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +const API_BASE = + process.env.CITADEL_API_BASE || `http://127.0.0.1:${process.env.CITADEL_PLAYWRIGHT_DAEMON_PORT || "4012"}`; + +// The /quick-capture page is daemon-served (outside /api/*), so the test hits +// the daemon directly rather than going through the cockpit web app. +test.describe("quick-capture page", () => { + test.beforeEach(async ({ request }) => { + // Reset the scratchpad so the captured block is the only one we see. + await request.put(`${API_BASE}/api/scratchpad`, { data: { content: "" } }); + }); + + test("submits a block via Cmd-Enter and the new block surfaces in the scratchpad", async ({ page, request }) => { + await page.goto(`${API_BASE}/quick-capture`); + const textarea = page.locator("textarea#t"); + await expect(textarea).toBeFocused(); + await textarea.fill("captured from /quick-capture"); + await textarea.press("ControlOrMeta+Enter"); + + // The page either closes (Chromium opens us as a regular tab so close is + // a no-op) or swaps for the confirmation message. Either way, the block + // is created — assert via the API rather than relying on the page's + // post-submit state. + await expect + .poll(async () => { + const res = await request.get(`${API_BASE}/api/scratchpad/blocks`); + const body = (await res.json()) as { blocks: Array<{ text: string }> }; + return body.blocks.map((b) => b.text).join("|"); + }) + .toContain("captured from /quick-capture"); + }); + + test("response is HTML and references the existing block endpoint", async ({ request }) => { + const response = await request.get(`${API_BASE}/quick-capture`); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toMatch(/text\/html/); + const body = await response.text(); + expect(body).toContain("/api/scratchpad/blocks"); + }); +}); diff --git a/e2e/scratchpad-mobile-deeplink.spec.ts b/e2e/scratchpad-mobile-deeplink.spec.ts new file mode 100644 index 00000000..6b82c25c --- /dev/null +++ b/e2e/scratchpad-mobile-deeplink.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +// Regression guard for the BLOCKER-2 fix: the mobile redirect must NOT eat +// /?modal=new-workspace. On a narrow viewport the cockpit should still +// render at / and auto-open the Create Workspace modal via the deeplink. +test.describe("scratchpad — mobile deeplink", () => { + test.skip(({ viewport }) => (viewport?.width ?? 0) > 820, "mobile-only behavior"); + + test("?modal=new-workspace lands on cockpit (no redirect) and opens the modal", async ({ page }) => { + await page.goto("/?modal=new-workspace"); + // URL must NOT be /scratchpad even though we're on a narrow viewport. + await expect(page).not.toHaveURL(/\/scratchpad$/); + // The existing Create Workspace modal opens via the deeplink. The modal + // header is "Create Workspace" — match loosely so future copy tweaks + // don't break the regression guard. + const heading = page.getByRole("heading", { name: /create workspace|new workspace/i }); + await expect(heading).toBeVisible(); + }); +}); diff --git a/e2e/scratchpad-mobile.spec.ts b/e2e/scratchpad-mobile.spec.ts new file mode 100644 index 00000000..ab1efc33 --- /dev/null +++ b/e2e/scratchpad-mobile.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +// Mobile-first scratchpad behavior. Skipped on the desktop/tablet projects so +// we don't false-positive — the redirect is gated on (max-width: 820px). +test.describe("scratchpad — mobile first", () => { + test.skip(({ viewport }) => (viewport?.width ?? 0) > 820, "mobile-only behavior"); + + test("bare root on a narrow viewport redirects to /scratchpad", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(/\/scratchpad$/); + const composer = page.locator(".scratchpad-composer-input"); + await expect(composer).toBeVisible(); + }); + + test("history sidebar is hidden by default; History toggle is present in the header", async ({ page }) => { + await page.goto("/scratchpad"); + const toggle = page.getByRole("button", { name: /history/i }); + await expect(toggle).toBeVisible(); + // Default: not open → list is not visible. + await expect(page.locator(".scratchpad-history-list")).toBeHidden(); + await toggle.click(); + await expect(page.locator(".scratchpad-history-list")).toBeVisible(); + }); +}); From ffce9272474f32d89b6105c6dc695cb57e56a560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Mon, 25 May 2026 21:48:30 +0000 Subject: [PATCH 09/13] chore(lint): biome format + organizeImports across touched files Auto-fixes from biome check --write across the files this branch touched; also picks up a stray pre-existing format nit in apps/daemon/src/terminal-routes.ts that surfaced once biome ran across the full surface for make check. Switches the speech-recognition test cleanup from `delete` to undefined-assignment to satisfy lint/performance/noDelete (the controller's resolveCtor uses ??, so undefined and missing are equivalent for the support probe). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/daemon/src/app.ts | 2 +- apps/daemon/src/quick-capture-route.test.ts | 10 ++++++---- apps/daemon/src/spa-fallback-route.test.ts | 10 ++++++---- apps/daemon/src/terminal-routes.ts | 5 ++++- apps/web/src/cockpit.tsx | 2 +- .../web/src/lib/speech-recognition-controller.test.ts | 11 +++++++---- apps/web/src/lib/speech-recognition-controller.ts | 4 +++- apps/web/src/routes/scratchpad.tsx | 5 +---- apps/web/src/scratchpad.css | 9 +++++++-- 9 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/daemon/src/app.ts b/apps/daemon/src/app.ts index d4cdd9d3..ab45cd3a 100644 --- a/apps/daemon/src/app.ts +++ b/apps/daemon/src/app.ts @@ -36,11 +36,11 @@ 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 { registerRuntimeUsageRoutes } from "./runtime-usage-routes.js"; import { registerScheduledAgentRoutes } from "./scheduled-agent-routes.js"; import { backfillIfEmpty } from "./scratchpad-history.js"; -import { registerQuickCaptureRoute } from "./quick-capture-route.js"; import { registerScratchpadRoutes } from "./scratchpad-routes.js"; import { scratchpadPath } from "./scratchpad.js"; import { registerSpaFallback } from "./spa-fallback-route.js"; diff --git a/apps/daemon/src/quick-capture-route.test.ts b/apps/daemon/src/quick-capture-route.test.ts index 7973d745..f9054d90 100644 --- a/apps/daemon/src/quick-capture-route.test.ts +++ b/apps/daemon/src/quick-capture-route.test.ts @@ -7,10 +7,12 @@ const servers: http.Server[] = []; afterEach(async () => { await Promise.all( - servers.splice(0).map( - (server) => - new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), - ), + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + ), ); }); diff --git a/apps/daemon/src/spa-fallback-route.test.ts b/apps/daemon/src/spa-fallback-route.test.ts index 3a784be8..8515b7d2 100644 --- a/apps/daemon/src/spa-fallback-route.test.ts +++ b/apps/daemon/src/spa-fallback-route.test.ts @@ -12,10 +12,12 @@ 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()))), - ), + servers + .splice(0) + .map( + (server) => + new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + ), ); }); diff --git a/apps/daemon/src/terminal-routes.ts b/apps/daemon/src/terminal-routes.ts index e291bf72..2157a0f1 100644 --- a/apps/daemon/src/terminal-routes.ts +++ b/apps/daemon/src/terminal-routes.ts @@ -266,7 +266,10 @@ export function registerTerminalRoutes(input: { return; } } - res.status(502).type("text/plain").send(error instanceof Error ? error.message : "terminal_proxy_failed"); + res + .status(502) + .type("text/plain") + .send(error instanceof Error ? error.message : "terminal_proxy_failed"); }); }); diff --git a/apps/web/src/cockpit.tsx b/apps/web/src/cockpit.tsx index e84ed071..9ad15b63 100644 --- a/apps/web/src/cockpit.tsx +++ b/apps/web/src/cockpit.tsx @@ -9,12 +9,12 @@ 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 { Stage } from "./stage.js"; import { UsageIndicator } from "./usage-indicator.js"; import { startColumnDrag, useCockpitLayout } from "./use-cockpit-layout.js"; import { useResolvedTheme } from "./use-resolved-theme.js"; -import { consumeNewWorkspaceDeeplink, shouldOpenNewWorkspaceModal } from "./lib/new-workspace-deeplink.js"; import { prToneFor } from "./workspace-card.js"; const STORAGE_LAST_WORKSPACE = "citadel.last-workspace"; diff --git a/apps/web/src/lib/speech-recognition-controller.test.ts b/apps/web/src/lib/speech-recognition-controller.test.ts index 491abc05..7dccaea6 100644 --- a/apps/web/src/lib/speech-recognition-controller.test.ts +++ b/apps/web/src/lib/speech-recognition-controller.test.ts @@ -20,7 +20,8 @@ class MockRecognition { continuous = false; interimResults = false; lang = ""; - onresult: ((event: { results: ArrayLike & { isFinal: boolean }> }) => void) | null = null; + onresult: ((event: { results: ArrayLike & { isFinal: boolean }> }) => void) | null = + null; onerror: ((event: { error: string }) => void) | null = null; onend: (() => void) | null = null; @@ -57,9 +58,11 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); - // Scrub the globals the support probe reads. - delete (globalThis as { SpeechRecognition?: unknown }).SpeechRecognition; - delete (globalThis as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition; + // 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", () => { diff --git a/apps/web/src/lib/speech-recognition-controller.ts b/apps/web/src/lib/speech-recognition-controller.ts index 85ca1b1d..41d5d31d 100644 --- a/apps/web/src/lib/speech-recognition-controller.ts +++ b/apps/web/src/lib/speech-recognition-controller.ts @@ -56,7 +56,9 @@ export function resolveCtor(): SpeechRecognitionCtor | undefined { return g.SpeechRecognition ?? g.webkitSpeechRecognition; } -export function createSpeechRecognitionController(input: SpeechRecognitionControllerInput): SpeechRecognitionController { +export function createSpeechRecognitionController( + input: SpeechRecognitionControllerInput, +): SpeechRecognitionController { const { Ctor, onTranscript, onError, onStateChange } = input; const silenceMs = input.silenceTimeoutMs ?? SILENCE_TIMEOUT_MS; let active: RecognitionInstance | null = null; diff --git a/apps/web/src/routes/scratchpad.tsx b/apps/web/src/routes/scratchpad.tsx index 76020779..bbf9606e 100644 --- a/apps/web/src/routes/scratchpad.tsx +++ b/apps/web/src/routes/scratchpad.tsx @@ -514,10 +514,7 @@ export function ScratchpadView() { )} -