A mute button on both call surfaces (full-screen call and floating
popout) that pauses everything going to the assistant while keeping the
call alive — for stepping away to talk to someone in the room without
ending and restarting the call.
- Mic audio stops reaching Deepgram (user mute OR'd into the existing
thinking/speaking setPaused; KeepAlives keep the socket warm so
unmute is instant)
- Camera/screen frames stop being sampled (new setCapturePaused in
useVideoMode); collectFrames() returns nothing while muted, so typed
messages during a mute carry no frames either
- Devices stay acquired for instant resume; mute resets at call
start/end; assistant output is unaffected (Stop handles that)
- Honest UI: "Muted" status chip replaces the green "Listening" pulse,
muted badge on the user tile, pill's share badge flips to "Sharing
paused"; new toggle-mic popout action + micMuted in popout state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a transport-agnostic messaging bridge in @x/core that drives the
session/turn runtime from your phone: WhatsApp links as a companion
device via Baileys QR pairing (self-chat is the command channel),
Telegram long-polls the user's own bot token. Commands: list, resume N,
new, status, stop; anything else runs a turn in the current session and
replies with the final assistant text. Ask-human questions are relayed
and answerable from the phone.
- packages/core/src/channels/: bridge (commands, settle watcher,
ask-human relay), WhatsApp + Telegram transports, config repo, service
(lifecycle, status fan-out, QR rendering, current-transport reply
routing, dynamic baileys import)
- Settings → Mobile tab: enable toggles, QR pairing, bot token,
sender allowlists
- Strict sender authorization: WhatsApp self-chat + allowlisted numbers
(LID-aware), Telegram allowlisted chat IDs only
- Telegram offset persisted across restarts; generation-guarded
WhatsApp socket lifecycle; vitest coverage for the bridge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove the "Mark as read" toggle for Everything else end-to-end (UI, IPC,
config, sync auto-read, markSectionRead) — it could bulk-mark backlog and
archived mail read on the server during cache-recovery syncs
- Sending an edited draft now reuses the draft's stored In-Reply-To/References
instead of self-referencing its own Message-ID (external clients thread
correctly); standalone drafts send as fresh messages
- Keep draft autosaves out of the sync pipeline: skip DRAFT history messages
(no more md/knowledge-event leaks, phantom "New email" notifications, or
per-autosave LLM reclassification) and mirror the draft body onto the cached
snapshot surgically instead of waking the sync loop
- Only recreate a Gmail draft when update fails with 404/410 — transient
errors no longer pile up duplicates
- Emptying a draft and closing now deletes it from Gmail (matches Gmail)
- Forward keydown out of message iframes so j/k/e/r etc. keep working after
clicking into an email body; restore expanded quotes across theme reloads
- Composer footer wraps on narrow panes: formatting toolbar + Discard move as
one unit instead of overflowing into each other
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Welcome step's "Use Next or your arrow keys" hint stays in the bubble
but is dropped from the voice clip (its tail kept synthesizing with
gibberish). Steps gain an optional voiceText override, honored by both
the clip generator and the live-TTS fallback; the generator also takes
step ids as args to re-roll individual clips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bundle the 11 tour narration clips as renderer assets so the tour never
calls ElevenLabs (works offline and signed-out). useVoiceTTS gains
speakUrl() which plays a ready URL through the same queue/analyser path,
keeping lip-sync and cancellation intact. Clips are regenerated with
scripts/generate-tour-audio.mjs, which parses TOUR_STEPS from
product-tour.tsx and synthesizes via @x/core. Default TTS voice is now
s3TPKV1kjDlVtZbl4Ksh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-time startup migration that scans ~/.rowboat/runs/*.jsonl and converts
each legacy run into the new event-sourced runtime, so existing chats and
agent history are visible and continuable after the runtime rewrite.
Mapping (a run is one whole conversation; turn boundaries are user messages):
- copilot_chat run -> 1 session (sessionId = original runId) + one turn per
user message (turn ids <runId>-tNNN).
- every other run -> a single standalone turn whose id IS the original runId,
so live-note (lastRunId) and background-task (runs.log)
history views resolve it via sessions:getTurn with no
renderer change.
- code_session runs are skipped (Code mode still uses the old runtime).
- convert.ts: pure convertRun(); synthesizes a reduceTurn/reduceSession-legal
event log (exact request refs, permission replay, denials -> runtime isError
results, reasoning parts preserved) and validates via the reducers before
returning.
- migrate.ts: defensive IO runner. Successful runs are moved to runs-archive/
(that move is the idempotency guard); failures are left in place (still
served by the runs:fetch fallback) and retried next launch. Per-run
try/catch never blocks boot. Writes config/runs-migration.json.
- main.ts: runs before sessions.initialize() and logs an [runs-migration]
summary (N turns across M sessions, skipped/failed counts).
- Tests: 13 cases over 4 real (redacted) run fixtures — conversion, session
chaining, deny->error, reasoning preservation, transcript fidelity,
archive-on-success, skip, quarantine, idempotency, and read-back through the
real FSTurnRepo/FSSessionRepo.
Dry-run on real data: 19 runs -> 3 sessions + 21 turns, 0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
List: j/k/arrow cursor with focus ring, Enter/o open, e archive, # trash,
u read toggle, c/n compose, / search, g+i / g+d view switch, ? help overlay,
layered Escape (suggestions > link bar > composer > thread > search).
Thread: r / a / f open reply, reply-all (with fallback), forward.
Composer: Cmd/Ctrl+Enter send (commits half-typed recipients), Cmd/Ctrl+
Shift+C/B reveal+focus Cc/Bcc, Esc closes with draft autosave.
Perf: content-visibility row virtualization, pointer-events suppression
while scrolling, memoized ThreadRow so cursor moves and page appends only
re-render affected rows.
Animations: 160ms row slide-out on archive/trash/draft-delete with snap-back
on failure, fast thread-detail and inline-composer open transitions, 140ms
ease-out animated scrolling for cursor-follow and thread-open (respects
prefers-reduced-motion).
Also fixes stale search-result rows on archive/read actions and Escape
closing the whole compose modal while a suggestion menu or link bar was open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analytics: the new runtime emitted usage into durable events but never
reported it to PostHog (only the permission classifier self-reported).
New IUsageReporter seam on the turn loop — invoked once per completed
model call, after the durable append, failure-isolated. The real bridge
emits the same llm_usage event as the old loop; useCase/subUseCase come
from the AsyncLocalStorage context the stage-6 headless runners already
establish via withUseCase, defaulting to copilot_chat for UI-driven
session turns (matching the old createRun default). Capturing at the
loop covers ALL turns — the session bus never sees headless ones.
Voice: the new renderer path rendered <voice> tags literally and never
fired TTS. The live overlay now extracts completed <voice> blocks
(robust to tags split across deltas) into chatState.voiceSegments;
App speaks new segments via the existing useVoiceTTS when voice output
is on, skipping segments streamed before a session became active. Tags
are stripped from both the streaming message and persisted assistant
messages, mirroring the legacy display-time strip.
Tests: usage report assertion in the runtime suite; four voice tests
(split-delta extraction, per-call scan reset, state stripping+segments,
persisted-message stripping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-task and live-note history views called the legacy runs:fetch
for ids that are now turn ids (ENOENT after stage 6). New shared loader
src/lib/agent-transcript.ts fetches sessions:getTurn first and falls
back to runs:fetch so pre-migration histories stay readable (fallback
dies with the runs runtime in stage 7); turn transcripts render through
the same buildTurnConversation used by the chat views. Unit-tested
(turnToTranscript mapping + failure surfacing).
The chat-log download used runs:downloadLog with what is now a session
id. Added sessions:downloadLog (concatenates the session's turn logs
into one JSONL via the save dialog); the sidebar tries it first and
falls back to runs:downloadLog for legacy background tabs.
Remaining renderer runs:* callers are code-mode only (use-code-chat,
runs:events feed) — the deliberate stage-7 carve-out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two follow-ups from live testing:
1. Permission/ask-human buttons were disabled while a turn waited for
input. PermissionRequest disables via isProcessing, whose old-runtime
meaning was 'agent busy right now' (false while awaiting approval);
the sessions meaning is 'turn not settled' (true while suspended).
Cards in both chat views now take the isThinking flag — actively
working disables, waiting on the user enables — while the composer
and stop button stay on isProcessing.
2. The middle-pane (full-screen) chat rendered blank: App.tsx has its
own conversation block whose activeChatTabState was still assembled
from legacy standalone states. activeChatTabState is now the single
hook-backed source of truth for the active tab; the sidebar props,
the middle pane, the merged background-tab map, and the submit guard
all derive from it (plus hook-driven activeIsProcessing/
activeIsThinking), so both views land on identical data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChatSidebar assembles the active tab's view state from individual props,
not from chatTabStates — and the four permission/ask-human maps were
still fed from legacy standalone state that nothing populates under the
sessions runtime. Pending permission requests (recorded correctly in the
turn file) therefore never rendered, leaving the chat on the
'Thinking…' shimmer.
- Feed pendingAskHumanRequests / allPermissionRequests /
permissionResponses / autoPermissionDecisions from the sessions hook
(legacy fallback until stage 7).
- New optional isThinking prop on ChatSidebar: waiting on a
permission/ask-human no longer shimmers (isProcessing still blocks
the composer, per the state contract).
- Session load failures are now visible: an error item renders in the
chat instead of a silent blank, and the store logs load/reload
failures to the console.
Verified the full data path against the real on-disk session (the
suspended list-downloads turn): conversation renders and the pending
permission card map carries the request, under StrictMode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chat stuck at 'Thinking…' with a completed turn on disk: the stores
subscribed to the sessions:events feed in their constructors while
cleanup was effect-managed, so React StrictMode's dev-mode
mount -> cleanup -> mount cycle tore down the one subscription and never
restored it. The renderer then loaded a mid-turn snapshot and was deaf
to every event after (the backend turn completed fine).
Feed attachment is now an effect-managed, idempotent connect() on both
stores; hooks return its disconnect as the effect cleanup. New tests pin
the bug: a StrictMode-style connect -> cleanup -> connect store test, and
the hook tests now render under a StrictMode wrapper.
Also removed the composer's runs:fetch model-freeze effect (the ENOENT
noise): sessions carry model/permission per message, so the picker stays
live for existing chats.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The active chat tab now renders from useSessionChat; background tabs keep
the legacy cached view state until stage 7 retires it.
- Send path: sessions:create + sessions:sendMessage with the structural
UserMessage (userMessageContext carries datetime + middle-pane — the
cache-safe per-message tier) and per-message turn config (model
override + composition: workDirId/voice/search/codeMode, permissionMode
-> autoPermission). Attachments/mentions ride as real content parts —
the old 'as unknown as string' cast is gone.
- Stop / permission / ask-human handlers route through the hook against
the latest turn; permission scope is passed as audit metadata (session
grants are a deferred v2 feature). Stop state clears when the turn
settles.
- Chat history lists sessions (sessions:list/delete); selecting one just
switches the active session — the hook loads the conversation, so the
200-line run-log replay in loadRun is gone.
- Work-dir persistence unchanged: the per-chat sidecar file is keyed by
the session id, which the resolver reads via composition.workDirId.
Renderer tsc/vite build clean; all 324 workspace tests pass; renderer
lint identical to baseline (108 pre-existing findings, none in new code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wire and state layer for the UI cutover; App.tsx integration follows
separately.
- shared/ipc.ts: eleven sessions:* invoke channels + the sessions:events
push feed (SessionBusEvent via z.custom, like runs:events). The generic
preload bridge needs no changes.
- main: sessions:* handlers as thin pass-throughs to the DI'd sessions
service; startSessionsWatcher forwards the session bus to all windows;
startup awaits the session-index scan before the renderer can list.
- renderer architecture per review guidance — all logic in framework-
agnostic, dependency-injected modules; hooks are thin
useSyncExternalStore subscriptions; components will consume pre-digested
view models:
- client.ts: narrow SessionsClient over window.ipc (fakeable).
- feed.ts: one shared sessions:events consumer with fan-out (factory
for tests).
- turn-view.ts: pure derivations — live overlay (deltas accumulate,
canonical events clear), TurnState -> ConversationItem[], and the
session chat state (permission/ask-human maps re-manufactured in the
runs-era shapes so existing components render unchanged;
isProcessing/isThinking contract preserved).
- store.ts: SessionChatStore (seed via getSession/getTurn, shared
reduceTurn over live events, prior-turn freezing, unknown-turn
reconciliation, stale-load guard, action routing) + SessionListStore.
- hooks/useSessionChat + useSessions: thin wrappers, deps injectable.
- renderer test infra added from scratch (vitest + jsdom +
testing-library); 29 tests across stores, pure views, feed, and hooks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(meetings): inline meeting prep under next meeting
Resolve a meeting's attendees against the knowledge base and render each
attendee's existing person.md inline beneath the next upcoming meeting in
the Meetings view. Deterministic (email-exact match, then unambiguous
name/alias), no LLM in the hot path.
- core: resolveMeetingPrep() + meeting-prep:resolve IPC
- renderer: notes-first rows with expandable person.md; unmatched attendees
collapse into a 'no notes yet' row with a Create note action that hands
off to the Copilot
- prep re-resolves when a People note changes
* feat(meetings): per-meeting prep toggle
Add a Prep toggle to every eligible meeting row, not just the next one.
The next meeting still auto-expands; other meetings resolve their attendee
notes lazily when their toggle is opened. All-day and solo events get no
toggle.
* feat(meetings): org matching, cached index, and field-bleed fix
#2 Matching: resolve the meeting's external companies from attendee email
domains (and matched people's Organization field), excluding the user's own
domain, and surface those org notes in a Companies section. Normalize display
names (strip '(via ...)' suffixes) before name/alias matching.
#3 Perf: meeting prep reads a cached knowledge index instead of rescanning the
whole knowledge dir on every resolve; invalidated whenever a file under
knowledge/ changes (wired to the workspace watcher).
Fix: extractField in the knowledge index consumed newlines after the label, so
an empty field (e.g. **Role:**) bled the next line's value. Restrict it to
spaces/tabs so empty fields resolve to undefined. Also strip the folder prefix
from link targets for display (Organizations/Rowboat Labs -> Rowboat Labs).
* feat(meetings): proactive prep notes generated 6h ahead
Generate a meeting prep note ~6h before each meeting and surface its brief
in the prep card.
- Generator (meeting_prep_brief.ts): assembles roster + 'last time' recap
(extracts the prior instance's Action items) + agenda, ordered by meeting
type (recurring -> recap first, one-off -> agenda first), then one model
call for a 'what matters' brief. Writes knowledge/Meetings/prep/<slug>-<date>.md
with eventId/recurringEventId frontmatter. Reuses the summarizeMeeting LLM
path (configured model, useCase: meeting_prep).
- Scheduler (meeting_prep_scheduler.ts): calendar-aware tick (5m), generates
prep within the 6h lead window, state file dedupes, self-heals on changes.
- Card: shows the brief (bulleted, compact) above a People list whose rows
link out to each person's note instead of rendering it inline. Generated
prep notes are kept out of the past-notes table.
* chore(meetings): poll prep scheduler every 15m instead of 5m
- Download search-result attachments on demand before opening (fixes Linux
where xdg-open reports success on a missing file so the old open-then-
download fallback never fired)
- Add a clear button to the search box that dismisses search and returns to
the inbox
- Remove the read toggle from the Important section
- Replace the Everything else "Read" toggle with a "Mark as read" preference:
turning it on marks all current and future "Everything else" mail read;
turning it off only stops auto-reading future mail, leaving current threads
untouched
Gmail client enhancements in apps/x, bundling four independent features:
- Drafts: save/autosave, update, delete, and list Gmail drafts. The
composer autosaves to a real Gmail draft (debounced ~1.5s) while
typing, reuses the thread's existing draft so edits update in place,
flushes a final save on close, and deletes the draft on discard. Adds
isDraft/draftId to the shared thread types. New core helpers:
saveThreadDraft, deleteThreadDraft, listDraftThreads,
buildDraftSnapshot, buildRawMimeMessage.
- Search: searchThreads(query, {limit}) backed by an on-disk snapshot
cache (read/writeSearchSnapshot). Extracts parseThreadSnapshot as the
shared parse core reused by both the cache-building sync and search.
- Read state: markThreadRead now takes a `read` flag so it toggles
read/unread; new markSectionRead marks a whole section
(important/other) read/unread and returns the affected count.
- Backfill: the onboarding/recovery sync is now bounded by a
configurable thread COUNT instead of a fixed 7-day window. New
gmail_sync_config.ts (getMaxEmails/setMaxEmails) backed by
~/.rowboat/config/gmail_sync.json, default 500, clamped to 1-5000;
seeded on first run.
New IPC channels: gmail:saveDraft, gmail:deleteDraft, gmail:getDrafts,
gmail:search, gmail:markSectionRead (plus a `read` field on
gmail:markThreadRead).