Commit Graph
4 Commits
Author SHA1 Message Date
Kevin AspesiandGitHub fb5cfb9fb9 Release the audio session when voice capture and playback go idle (#2866)
* Release the audio session when voice capture and playback go idle

Opening Paseo permanently paused music playing in other apps. Once the
mic had been used a single time, every subsequent foreground of the app
killed the user's audio again until the app was force-quit.

iOS AudioEngine.setupAudioSession() takes .playAndRecord/.voiceChat — a
non-mixing category — and calls setActive(true), but the only
setActive(false) was reachable from tearDown(). Nothing handed the
session back after a normal dictation turn, so iOS re-asserted the last
category the app set on every foreground. The iOS module had no
app-lifecycle handling at all, while Android already had a lifecycle
listener; that asymmetry is what made the platforms diverge. Android had
the same leak in a different shape: AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
was requested per recording and never abandoned.

Add releaseAudioSession() to both platforms and call it once capture
stops and the playback queue drains. iOS pauses the engine, calls
setActive(false, .notifyOthersOnDeactivation) — this is what tells the
other app to resume — then drops to .ambient/.mixWithOthers so any
implicit reactivation mixes instead of interrupting. The session is
re-acquired lazily on toggleRecording(true)/playPCMData, so voice keeps
working; resumeRecordingAndPlayer() re-activates before restarting the
engine, which keeps post-interruption resume intact. Releasing is
guarded on isRecording/isPlaying, and checkEngineIsRunning() now refuses
to start the engine while the session is released, since starting it
would silently re-take the session.

The JS wrapper probes the native module before calling, because a JS
bundle shipped over EAS Update can land on a binary predating this
function.

* Correct the iOS signing diagnosis in mobile-testing docs

The "No Accounts" error does not mean no Apple ID is configured. The
account can be registered in Xcode while its Xcode-Token keychain item is
missing, which Xcode reports as No Accounts and then compounds by falling
back to a stale cached profile — surfacing as a certificate mismatch that
sends you chasing the wrong thing.

Document the DVTDeveloperAccountManager log line that names the real
cause, the two commands that check what is actually configured, and the
build-unsigned-then-sign-manually stopgap.

* Reacquire Android audio focus when playback restarts

releaseAudioSession() abandons the AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
request and pauses the AudioTrack once capture and playback go idle, but
nothing took either back. The next playback turn therefore ran with no
focus held, so the assistant's response competed with whatever the user
was listening to instead of interrupting it.

Add acquireAudioSessionIfNeeded(), the mirror of the iOS
activateAudioSessionIfNeeded() already called from playPCMData, and use
it from both playPCMData() and resumePlayback(). A null audioFocusRequest
is what "we released" looks like, so it doubles as the guard against
re-requesting focus mid-turn.

Reported by Greptile on #2866.

* Make Android playback focus reacquisition non-fatal

The previous commit dropped PCM data when focus reacquisition did not
return AUDIOFOCUS_REQUEST_GRANTED. That is worse than playing unfocused:
playPCMData is fire-and-forget from JS, and playAudio() settles on a
duration timer rather than a native ack, so the runtime acknowledged and
discarded a chunk that was never queued.

It also broke delayed focus. The request is built with
setAcceptsDelayedFocusGain(true), so AUDIOFOCUS_REQUEST_DELAYED is a
normal outcome, but routing it through handleAudioFocusBlocked() abandoned
the pending request and the later grant could never arrive.

Request focus, then play regardless of the result, and leave the request
outstanding so a delayed grant still lands via the listener.

Reported by Greptile on #2866.

* Track scheduled buffers so iOS actually releases after playback

releaseAudioSession() guarded on !speechPlayer.isPlaying, but
AVAudioPlayerNode.isPlaying stays true from play() until an explicit
stop()/pause() — draining every scheduled buffer does not clear it. The
normal completion path is processQueue() -> releaseSessionIfIdle(), and
stopPlayback() is only reached from the explicit stop() API, so after the
first spoken response the guard blocked every release and the session
stayed active. The music-pausing bug returned in full for anyone using
voice responses.

Count buffers that are scheduled but not yet rendered, decrementing from
the .dataPlayedBack completion handler, and guard on that instead. The
handler runs on an AVAudioEngine-internal thread, so the count is behind a
lock. Both stop() paths reset it, since stop() discards what is scheduled
and a stale count would block every later release.

Found by Codex review on #2866.

* Stop re-taking Android audio focus on every foreground

ExpoTwoWayAudioLifeCycleListener calls resumeRecordingAndPlayer() from
onResume, and that requested AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE before
looking at isRecordingBeforePause. Foregrounding the app while nothing was
recording therefore took exclusive focus back and re-paused the user's
music — the same "every foreground kills my music" symptom this change set
exists to remove, just reached through the Android lifecycle instead of the
iOS category.

Return early unless capture or playback was actually live when we
backgrounded.

Found by Codex review on #2866.
2026-08-05 22:53:23 +02:00
Kevin AspesiGitHubpaseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
429155b536 Add collapsed-project status badge to the sidebar (#2340)
* Add collapsed-project status badge to the sidebar

Collapsed project rows now surface the most urgent hidden workspace's
status as a corner badge on the project icon, instead of giving no
signal at all: a loader for running, an alert for needs_input, a dot
for attention/failed, nothing for done. Priority order (needs_input >
failed > running > attention > done) means a working project keeps
its loader even if another workspace is also awaiting review.

Every surfaced status shares one badge shell (size, ring, offset) so
the loader, alert, and dot never draw a differently-sized circle -
previously the loader/alert used a 12pt ring while the dot used a
7-9pt one with a different corner offset. The running loader is also
nudged half a point left so its visual center lands on the circle's
center rather than reading as shifted right.

Adds an e2e capture spec (run on demand, not part of CI) that seeds
every state and edge case through the mock provider and dumps DOM
geometry, for re-verifying the badge shell stays uniform.

* Add PR review gallery images for the status badge PR

Temporary evidence assets for pull request review, embedded via blob
URLs in the PR description. Safe to delete after merge.

* Swap misleading before/after PR images for clean per-state stills

This feature never shipped on main, so there is no real "before" to
compare against for a reviewer — the earlier before/after images were
an artifact of local dev iteration, not a regression fix. Replaced
with one clean still per status.

* Fatten needs-input alert and dot to fill the shared badge shell

Lucide's circle-alert only paints ~83% of its nominal size, so the
prior 10pt glyph read as smaller than the loader inside the same
14pt shell. Sized both up so all three glyphs draw the same visual
footprint against the shared ring.

* Address review: extract pure badge-content selection, drop JSDOM tests

Greptile flagged getStatusDotColorStyle's needs_input/running cases as
dead (unreachable — shouldRenderSyncedStatusLoader and an explicit
check already intercept those buckets earlier). Extracting the
selection logic into a plain-TS descriptor function
(getProjectStatusBadgeContent) makes that structural: the "dot"
variant is typed to failed|attention only, so there's no switch left
with unreachable branches.

That extraction also resolves the JSDOM component test Greptile
flagged (project-leading-visual.test.tsx used vi.hoisted/vi.mock/
createRoot mounting, all banned by docs/testing.md's two test
categories) — the pure selector is now tested directly with a plain
vitest file, no mounting needed. Dropped that test file and
use-sidebar-project-status-bucket.test.tsx (same banned pattern); the
real coverage was already redundant with sidebar-workspaces-view-model
.test.ts's existing priority-order assertions and the e2e capture
spec's real-app gating checks.

Also split a conditional branch in the e2e capture spec's assertion
loop into two deterministic loops (badge-expected vs no-badge cases)
per Greptile's third note.

* Size status-badge glyphs even so they center in the shell

The badge shell's interior is 12pt (14pt minus the 1pt ring on each
side). A centered glyph of size N sits at a (12 - N) / 2 offset, which
is fractional for odd N and gets snapped to a device-pixel boundary,
rendering visibly off-center (~1.5 device px at 3x). Drop the dot to 8pt
and the alert to 10pt so both divide the interior into whole pixels and
land dead center; Lucide's circle-alert paints ~83% of nominal, so 10pt
draws a ~8.3pt disc that matches the 8pt dot.

Also extends the on-demand capture spec to cover the failed bucket, the
priority aggregation cases, dark mode, hover, a compact viewport, and a
recorded running-to-expand transition, dumping DOM geometry that proves
every shell shares one size/offset and every even glyph centers at 0px.

* Refresh status-badge PR assets for the even-glyph sizing

Recaptures every state at the approved 8pt-dot / 10pt-alert sizing,
adds the failed bucket to the per-status set, a crosshair sizing/
centering proof, dark-mode and compact-form-factor shots, and replaces
the soft upscaled spinner GIF with a crisp one assembled from native
3x-scale frames. Drops the stale pre-sizing images.

* Add capture spec proving workspace statuses roll up to the project badge

Seeds three collapsed projects, each with several same-directory
workspaces in different buckets, so each resolves to a different
highest-priority winner (needs_input / failed / running). Films the
collapse and expand so the aggregation is provable as motion, not a
single frame.

* Add propagation recording showing statuses resolving to the project badge

* Add higher-quality mp4 of the status propagation recording

* Refactor project status badge coverage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-08-02 00:37:22 +08:00
47c18a9213 Fork an agent session mid-run from the in-flight turn footer (#2638)
* feat(app): fork an agent session mid-run from the tab menu

Forking already existed, but only on the footer of a *completed*
assistant turn. While an agent is running, the in-flight turn renders
RunningTurnFooter with no fork menu, so exploring another path meant
scrolling back to hunt for an older turn's footer — or waiting for the
run to finish.

Add two entries to the agent tab context menu ("Fork chat in a new tab",
"Fork chat in a new workspace"), reachable regardless of run state. They
omit the fork boundary, and selectForkContextRows projects the whole
timeline when no boundary is given, so a mid-run fork carries everything
up to now — including the partially streamed in-flight turn.

Fork logic moves out of agent-stream/view.tsx into a shared useForkAgent
hook so the turn footer and the tab menu share one code path; the
per-turn behavior is unchanged (it still pins a boundary to its turn).

No server, protocol, or capability changes — this reuses the existing
agent.fork_context RPC and agentForkContext host feature. The daemon
handler is already a pure in-memory timeline read, so it was safe
mid-turn before this change.

* fix(app): resolve missing agent records before failing a fork

handleForkAgent read the agent record from the session store and gave up
if it was absent from both agents and agentDetails. A persisted agent tab
can outlive its record — restored on startup before hydration — so both
fork entries failed with a generic toast even though the agent exists on
the daemon and is perfectly forkable.

Fall back to client.fetchAgent and store the result before failing,
mirroring the resolution path agent-panel.tsx already uses. Only report
forkFailed when the agent genuinely cannot be resolved; a disconnected
host now says so instead.

The resolution logic lands in resolveForkAgentSource with injected
dependencies, since workspace-screen.tsx has no handler-level test
harness and a sibling testable module is the pattern in this directory.

storeFetchedAgentDetail moves out of panels/agent-panel.tsx into
utils/agent-detail-store.ts so the workspace screen doesn't import a
panel module — and into its own file rather than agent-directory-sync.ts,
which would close an import cycle through legacy-daemon-workspaces.

* feat(app): fork from the in-flight turn instead of the tab menu

Move the mid-run fork affordance to where the fork button already lives:
the assistant turn footer, beside the progress loader.

RunningTurnFooter previously showed only a spinner and elapsed timer, so
the fork button vanished for the whole duration of a run. It now renders
AssistantForkMenu alongside them, reachable the moment a run starts.

The in-flight fork passes no boundary, so selectForkContextRows projects
the whole timeline and the fork captures the response being streamed. A
fork button sitting next to a live response that silently dropped that
response would be worse than no button. Completed turns still pin their
own boundary, enforced by a separate handler type that keeps boundary
required.

layout.ts is untouched — the affordance is added to the running footer
component rather than by lifting the footer suppression, so no copy or
duration affordance leaks onto an incomplete turn.

Reverts the workspace tab context menu approach, along with the agent
resolution helper and store-writer extraction that existed only to serve
it. The footer path already holds the agent record.

* test(app): cover the in-flight turn fork with a Playwright spec

Extends the existing assistant fork menu spec rather than adding a
sibling file, so the completed-turn and in-flight cases sit side by side.

The new test pins the behavior that defines the feature: the in-flight
fork omits the boundary fields entirely, while the completed-turn fork
sends one. Both assertions read the actual agent.fork_context frames off
the WebSocket, using the read-only page.on("websocket") idiom from
helpers/timeline-delivery.ts so the observer cannot perturb the flow it
measures. Note the request omits the fields rather than nulling them —
daemon-client spreads them conditionally — so absence is what's asserted
there, and null only on the response.

Also asserts the fork trigger is scoped inside turn-working-indicator,
that no copy or duration affordance leaks onto the incomplete turn, that
the attachment carries the streamed text, and that the source agent keeps
streaming afterwards.

* test(app): cover the in-flight fork call site, gate, and teardown

Three behaviors this PR introduced had no coverage below the Playwright
spec. A copy-paste of resolveAssistantTurnForkBoundary into
handleForkInFlightTurn would have left every unit test green.

view-fork-in-flight.test.tsx mounts the real AgentStreamView with the
render strategy stubbed to the live-auxiliary slot, so handleForkInFlightTurn,
useStableEvent and the readOnly expression all stay real. It asserts the
fork request has no boundary KEY — matching the wire contract, where the
field is absent rather than null — for both targets, and that the read-only
gate withholds the trigger, with positive controls either side so an empty
tree cannot pass for a working gate.

turn-footer.test.tsx covers the race this PR created: the fork menu now
mounts only while a run is in flight, so a fork can be pending when the
subtree is torn down. The behavior is benign — React 18 makes the orphaned
setState a no-op — so the test asserts the observable outcome and leaves a
tripwire for unhandled rejections, since AssistantForkMenu's finally has no
catch.

Each test was verified by mutation: adding boundary: undefined, dropping the
readOnly gate, and keeping the running subtree alive each fail the matching
test.

Scoped to what this PR changed. The relocated hook's error paths, draft
setup construction, and attachment metadata predate it and stay untested
here.

* test(app): replace mocked fork coverage with Playwright

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-01 16:41:30 +02:00
350bc08fc4 fix(app): make markdown links tappable on iOS (#1334)
* fix(app): make markdown links tappable on iOS

A markdown link's label renders as nested React elements
(link > textgroup > text -> MarkdownInheritedText spans). The link's
onPress was placed on the wrapping UITextView span, but
react-native-uitextview only attaches onPress to the *string* children
it converts into tappable RNUITextViewChild nodes - element children
pass through untouched, so the handler never reached a node the native
tap recognizer could fire. Link text stayed visible and selectable but
dead to taps; web links never opened.

Thread the link's press handler down to the leaf text spans via a new
AssistantLinkPressProvider so it lands on the real tappable string
nodes. MarkdownInheritedText consumes the context and forwards onPress
to its MarkdownTextSpan; null outside a link, so ordinary text is
unaffected. Gated to iOS - Android forwards onPress through nested
<Text> already, and web uses the <a> path.

* refactor(app): export useAssistantLinkPress from module index

Import it through the assistant-file-links public surface instead of
reaching into the internal link-press-context file, matching the rest of
message.tsx. Addresses Greptile review feedback on #1334.

---------

Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
2026-06-05 20:43:09 +08:00