Skip to content

feat(scratchpad): voice + mobile-first + Mac satellite quick-capture#28

Open
ovdmar wants to merge 15 commits into
mainfrom
agent/scratchpad-capture-voice-mobile-dt70fe
Open

feat(scratchpad): voice + mobile-first + Mac satellite quick-capture#28
ovdmar wants to merge 15 commits into
mainfrom
agent/scratchpad-capture-voice-mobile-dt70fe

Conversation

@ovdmar

@ovdmar ovdmar commented May 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Voice capture (Web Speech API, iOS-Safari-compatible) on the cockpit scratchpad composer and per-block editor.
  • Mobile-first scratchpad: on viewports ≤820px, the bare root URL redirects to /scratchpad; layout tightened (composer safe-area, History sidebar behind a toggle, ≥36×36 tap targets).
  • New daemon-served GET /quick-capture Spotlight-style HTML page + scripts/mac-satellite/ shell helpers wiring cmd+shift+s (quick-capture) and a sibling shortcut (new-workspace via cockpit deeplink ?modal=new-workspace).

Plan

.agents/plans/scratchpad-capture-voice-mobile.md

Out of scope (deferred to a follow-up): a native Tauri/Electron Mac .app that wraps the same /quick-capture page and registers the global shortcut itself. This PR's surface area is forward-compatible — the follow-up reuses the same URL and the same deeplink.

Test plan

  • make check passes locally (typecheck, biome, vitest, coverage, deps, build)
  • pnpm e2e passes locally — three new specs added (mobile redirect, mobile deeplink regression guard, quick-capture happy path)
  • pnpm smoke — daemon HTTP surface gained GET /quick-capture; recommend a quick smoke run
  • Manual QA on a real iPhone (Web Speech API can't be driven headlessly) — see How to QA below

Test surface added

  • 5 new unit-test files: append-transcript, bootstrap-redirect, mobile-redirect, new-workspace-deeplink, speech-recognition-controller (+ quick-capture-route, spa-fallback-route on the daemon side). 35+ new assertions including a regression test for the daemon route-registration order that catches future swaps which would let the SPA wildcard swallow /quick-capture.
  • 3 new E2E specs: scratchpad-mobile, scratchpad-mobile-deeplink (BLOCKER-2 regression guard), quick-capture.

How to QA

  1. Pull the branch: `git checkout agent/scratchpad-capture-voice-mobile-dt70fe`
  2. Install: `pnpm install`
  3. Full check: `make check`
  4. Start dev: `pnpm dev` (daemon http://localhost:4010, web http://localhost:5173)

Then:

  • Open http://localhost:5173 in a desktop browser → cockpit renders as before (mobile redirect does not fire above 820px).
  • Resize to ≤820px → bare root redirects to /scratchpad. Composer is focused, mic button visible (in Chrome), History sidebar hidden by default with a toggle in the header.
  • Click the mic button (Chrome/Edge/Safari with mic permission) → dictate a sentence → text appears in the composer. Click again or blur the textarea → recognition stops.
  • Open http://localhost:4010/quick-capture in a popup (Cmd+Click in Chrome, or use the helper script below) → type a thought → ⌘+Enter → block appears in the cockpit's scratchpad.
  • Open http://localhost:5173/?modal=new-workspace → cockpit auto-opens the Create Workspace modal and strips the param.

For the Mac satellite shortcuts (macOS only):

  • `./scripts/mac-satellite/quick-capture.sh` opens the Spotlight popup.
  • `./scripts/mac-satellite/new-workspace.sh` opens the cockpit pre-deeplinked to the modal.
  • See `scripts/mac-satellite/README.md` for Hammerspoon / Shortcuts.app binding.

For E2E coverage:

  • `pnpm exec playwright test e2e/scratchpad-mobile.spec.ts e2e/scratchpad-mobile-deeplink.spec.ts e2e/quick-capture.spec.ts`

🤖 Generated with Claude Code

ovdmar and others added 15 commits May 25, 2026 21:23
…satellite

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.
apps/daemon/src/app.ts was at 804 lines (file-size gate cap is 800;
scripts/checks/file-size.ts reported it as 805). Extracting the SPA
shell + static-asset fallback into a sibling module preserves behavior
verbatim (including the /api/* and /events fall-through guard) and drops
app.ts to 797 lines, restoring margin for upcoming work.

The new helper also owns the default web/dist path resolution so callers
don't have to repeat the import.meta.url dance — fileURLToPath is now
unused in app.ts and the import is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a microphone toggle next to the cockpit scratchpad composer and the
per-block editor. Tapping dictates into the textarea; another tap, blur,
or 10s of silence stops recognition.

Logic lives in a non-React controller (speech-recognition-controller.ts)
so the silence-timer behavior and the iOS-Safari synchronous-throw path
are unit-testable without RTL. useSpeechRecognition is a thin React
adapter, VoiceCaptureButton is a stateless lucide-icon toggle that
returns null when neither SpeechRecognition nor webkitSpeechRecognition
is exposed — older browsers and locked-down environments get no UI at
all rather than a non-functional button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ratchpad

On viewports matching (max-width: 820px), a bare landing at the cockpit
root now redirects to /scratchpad so opening Citadel on iPhone goes
straight to the capture surface. Deeplinks with any search or hash
(including /?modal=new-workspace) are unaffected — the redirect mirrors
the isBareRootLanding semantics in last-route.ts.

The pre-router gate (applyBootstrapNavigationFromWindow) intentionally
runs the mobile rule BEFORE bootstrapLastRoute so a mobile user with a
persisted desktop route (e.g. /settings) still lands on scratchpad. On
desktop the mobile rule no-ops and bootstrap restores the saved route
unchanged.

Mobile-tightened scratchpad layout: composer pinned with safe-area
padding (viewport-fit=cover now in index.html so env() is non-zero on
iOS), history sidebar collapsed behind a History toggle in the page
header, mic/history controls sized to ≥36×36 tap targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…page

A self-contained HTML page (inline CSS+JS, no bundler) designed to be
opened in a chromeless popup by external shortcuts. It posts to the
existing POST /api/scratchpad/blocks — no new write surface — and on
success attempts window.close(). In Chrome --app= mode close succeeds;
in Safari and regular tabs the close call no-ops and the page swaps
itself for a "Captured. Press ⌘W to close." confirmation ~60ms later.

Includes the same Web Speech API mic toggle as the cockpit composer
(graceful absence when neither SpeechRecognition nor webkitSpeechRecognition
is exposed). Registered before registerSpaFallback so the SPA wildcard
doesn't swallow the path; route tests guard against accidental sub-path
matching and method confusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…modal

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…gets

Addresses the surviving findings from /review-pr:

- VoiceCaptureButton: move onError invocation from render into a
  useEffect keyed on speech.error so a stateful onError can't cause a
  render loop. Expose an imperative `stop()` via controlRef so hosts
  can satisfy spec B.2 #9 ("blurring stops voice capture").
- Scratchpad composer + per-block editor: wire the new mic stop()
  handle into their onBlur paths so dictation stops on textarea blur.
- Extract `appendTranscript()` (composer/editor used to copy-paste the
  trim-and-join one-liner). The daemon-served quick-capture page keeps
  its inlined copy because it's bundler-free.
- speech-recognition-controller.dispose(): null the onresult/onerror/
  onend handlers BEFORE abort() so the async onend the browser fires
  during teardown cannot reach stopAndReport on a disposed controller.
  New test simulates the late callback.
- scratchpad.css mobile breakpoint: enlarge .scratchpad-block-delete
  to 36×36 to meet spec B.2 #11 tap-target requirement.
- specs/B.2-ade-cockpit.md line 108: change "per-workspace" → "global"
  to match the B.7 storage description.
- quick-capture-route.ts: template the silence timeout from the new
  QUICK_CAPTURE_SILENCE_TIMEOUT_MS constant instead of a magic 10000.
- New integration test (quick-capture-route.test.ts) registers both
  the quick-capture route AND the SPA fallback in both orders and
  asserts the inline HTML wins only when registered first. Catches
  any future swap that would let the SPA wildcard swallow the page.
- New bootstrap-redirect test for the primary AC2 case: narrow + bare
  + no saved route → /scratchpad.
- E2E scratchpad-mobile: assert composer focus after redirect and
  mic boundingBox ≥ 36×36 (skipped when SpeechRecognition not exposed
  in headless Chromium).
- E2E scratchpad-mobile-deeplink: tighter assertion that the URL stays
  bare-root (not /scratchpad) and that consumeNewWorkspaceDeeplink
  strips the modal param after mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…odal mount

CI surfaced two interactions with the mobile-first scratchpad redirect
that didn't fail locally:

- e2e/operator-cockpit.spec.ts navigates to "/" on the mobile project
  and expects the cockpit shell. The new redirect now sends bare-root
  mobile loads to /scratchpad, so the test never sees the shell. Swap
  both gotos to "/?from=cockpit-test" — any non-bare URL skips the
  redirect (isBareRootLanding requires empty search). The cockpit
  shell is what the test exercises; the redirect's bare-URL behavior
  is covered by scratchpad-mobile.spec.ts.
- The Create Workspace modal is rendered as a child of the Navigator
  column. On mobile the column has `display: none` by default
  (mobileView starts at 'stage'), so the modal mounts into the DOM
  but is invisible — the deeplink test asserted toBeVisible() on the
  modal heading and failed. Cockpit's deeplink useEffect now also
  calls setMobileView("navigator") when ?modal=new-workspace fires;
  desktop is unaffected (mobileView has no visual effect there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pture

Phase 2 of the mac-satellite work, originally deferred but folded into
this PR per user request. The native shell now ships alongside the
existing scripts/mac-satellite/ POSIX-shell fallbacks.

apps/mac-satellite/ — Electron app that registers two global shortcuts:
  ⌘⇧S  → Spotlight-shaped frameless BrowserWindow loading the daemon's
         existing /quick-capture page. Toggle-like: a second press while
         the popup is focused closes it. Esc closes too.
  ⌘⇧N  → opens the cockpit at /?modal=new-workspace in the user's
         default browser; the cockpit's deeplink auto-opens the
         Create Workspace modal.

Runs as a background utility (app.dock.hide()), single-instance,
re-resolves the daemon target on every press so env-var overrides
take effect after a restart. Liveness probe (2s, fetch with
AbortController) surfaces a native notification if the daemon
isn't reachable rather than silently failing.

No new daemon endpoints — all traffic goes through the surfaces this
PR's earlier commits added (GET /quick-capture, POST /api/scratchpad/
blocks, /?modal=new-workspace deeplink). The shell-script helpers under
scripts/mac-satellite/ remain as the no-Electron path.

Electron over Tauri: keeps CI on its existing pnpm/TS toolchain (no
Rust), the satellite is ~200 LOC, and globalShortcut does exactly what
we need. Pinned electron@33.2.1 in apps/mac-satellite/devDependencies;
added to pnpm-workspace.yaml allowBuilds so the postinstall binary
download runs. apps/mac-satellite/ added to the root tsconfig
project-references list so check:size, typecheck, lint, and test all
sweep it like any other workspace.

Pure helpers (config.ts: daemon URL resolution, env-var precedence,
Spotlight popup geometry with multi-monitor offsets) are unit-tested
in src/config.test.ts. The Electron main process typechecks but is
exercised manually with `pnpm --filter @citadel/mac-satellite dev`
because Electron's runtime is not headless-testable in CI.

apps/mac-satellite/README.md documents the dev flow, configuration,
trust model, and the Electron-vs-Tauri choice. scripts/mac-satellite/
README updated to point at the new app as the recommended path. The
C-technical-stack spec describes both deliveries (Electron app +
shell-script fallback) and which CI gates apply to each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After merging origin/main, biome flagged a single-line expression that
status-monitor-wiring.ts's status-monitor-wiring brought in from the
new 2e31415 status-monitor work. One-line format fix; no behavior
change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant