Skip to content

refactor(frontend): remove continuous voice interrupt button - #297

Merged
Wintercom merged 1 commit into
mainfrom
feat/remove-continuous-interrupt-button
Aug 19, 2026
Merged

refactor(frontend): remove continuous voice interrupt button#297
Wintercom merged 1 commit into
mainfrom
feat/remove-continuous-interrupt-button

Conversation

@gac0812

@gac0812 gac0812 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 后端已实现连续语音打断(barge-in / voice.tts.canceled),用户开口即可打断当前播报,无需前端再提供「打断当前对话」按钮。
  • 去掉免提通话全屏层的打断按钮,以及接到 dismissReply 的相关前端逻辑;底部只保留「结束对话」。
  • 开口打断与按住说话的回复气泡关闭不受影响。

Test plan

  • 进入免提连续通话:底部没有「打断当前对话」,只有「结束对话」
  • 「结束对话」仍可挂断;左上角收起仍不挂断
  • 助手正在播报时开口说话,仍能打断当前 TTS 并继续听
  • 按住说话的回复气泡点一下仍可关闭

Backend barge-in already cancels TTS, so the call screen no longer needs a user-facing interrupt button.
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查结论

已核对连续通话打断按钮的完整移除链路:VoiceCallScreen 的属性与控件、AssistantVoiceOverlay 的调用接线以及对应单元测试保持一致;按住说话模式的回复气泡关闭逻辑未受影响。固定提交范围的 diff 检查无误,相关 2 个 Jest 测试套件(9 个测试)及 TypeScript 类型检查均通过,未发现需要提出的缺陷。

View job run

@github-actions

Copy link
Copy Markdown

Android 预览(Appetize)

在浏览器中打开并操作本次提交构建的 Android APK(无需安装):

打开预览

新的提交会更新本条评论中的链接;请勿手动编辑。

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@Wintercom
Wintercom merged commit 9b2859c into main Aug 19, 2026
6 checks passed
@gac0812
gac0812 deleted the feat/remove-continuous-interrupt-button branch August 19, 2026 01:43
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

1024XEngineer#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from 1024XEngineer#245,
which this stacked series replaces -- not reminder-scoped, so none of
1024XEngineer#264-1024XEngineer#270 picked it up.
gac0812 pushed a commit that referenced this pull request Aug 20, 2026
…271)

* feat(reminder): add SQLite-backed reminder data layer

Part of #263.

SqliteLocalScheduleReader / SqliteReminderStateStore read and persist
against the real local database (ScheduleLocalRepository) instead of
in-memory fixtures; geofence_radius_meters is hardcoded to 200m for
now (known simplification, see Issue #263 Out of Scope).
InMemoryLocalScheduleReader is kept as a non-persisted alternative
implementation of the same port. LocalScheduleWriter's post-write hook
refreshes the new reader after a voice-driven schedule mutation lands.

Only depends on application interfaces already on main and the
existing ScheduleLocalRepository -- independent of the audio/location/
notifications adapter PRs in this stack.

Removes MockLocalScheduleReader, MockReminderApplication,
MockReminderDispositionSync, MockReminderStateStore, mockReminderSchedules.

* feat(reminder): wire real engine into the app, drop remaining mocks

Swaps the composition root over to the real implementations added in
the previous three commits: LocalReminderApplication replaces
MockReminderApplication, SqliteLocalScheduleReader/SqliteReminderStateStore
replace their Mock counterparts, and every device port
(audio/notification/vibration/alarm/location) now points at its real
adapter. ExpoLocationMonitor (system geofencing) is used for location
monitoring; NativeLocationMonitor (Baidu SDK) stays in the repo but
unwired -- see the comment in createAppServices.ts for how to switch.

AppProviders/AppRoot gain the reminder permission-request flow
(useReminderPermissionsOnLaunch, now driven by an injected AlertDialogPort
instead of calling Alert.alert directly, with a settings-page fallback
for denied background-location permission) and rebuild() the engine
once permissions change.

Removes MockReminderPresenter, the last remaining Mock* adapter.

* fix(reminder): permission flow stalled after the first prompt

Every branch in promptNext() except the overlay/full_screen/battery
confirm-and-continue path returns from inside the try block, so the
setTimeout(runPrompt, 250) that was meant to advance to the next
missing permission -- placed after the try/finally -- was dead code
for those branches. In practice: grant notifications, and exact_alarm
(the next permission in line) would just never get prompted; same for
any declined dialog, or a granted/denied location permission. Moved
the continuation check into the finally block so it always runs
regardless of which branch returned.

Also replaces the old mock-based useReminderPermissionsOnLaunch test
(deleted upstream when this hook's signature changed to take an
injected AlertDialogPort + onPermissionsUpdated callback, with no
replacement written) and drops the now-redundant .gitkeep placeholders
left over from directories that have had real files in them since
earlier commits in this stack.

* fix(voice): remove stale push-to-talk listeners

AssistantConversationService.handleClose() nulled unsubscribeConnection
without calling it. Switching from push-to-talk to continuous mode makes
the shared AuthenticatedWebSocketClient drop and reopen the connection
(the two modes negotiate different voiceMode), so the old service stayed
subscribed to the new connection's TTS/PCM and pushed the same reply into
the player alongside the continuous service -- audible as one sentence
played twice, overlapping.

dispose() already unsubscribed correctly; only the close path was missing
it. Carried over from #245, which this stacked series replaces -- the fix
is not reminder-scoped so none of #264-#270 picked it up.

* fix(voice): preserve new TTS after interruption

A barge-in that lands after the model already finished delivering a reply
cancelled whatever was playing *next*, not the reply the phone was still
sounding out. Two halves:

Backend: _Turn reset _audio_id before that late interrupted() ran, so it
sent AudioCanceled(audio_id=""). Added _last_audio_id, which survives the
reset, and send that instead -- the cancellation now names the audio it
actually refers to.

Frontend: voice.tts.canceled routed stop() through playbackChain, so every
PCM chunk already queued was still fed to the native player before the stop
landed. It now bypasses the chain via stopPlaybackImmediately(), and
chainPlayback() tags each queued operation with a playbackGeneration that
stop bumps, so the stale queue is dropped rather than replayed. tts.end and
tts.canceled are both matched against currentAudioId/canceledAudioId, so
the server's follow-up tts.end for a cancelled reply no longer ends a newer
stream or flips interrupted back to listening.

The empty-audio_id case is still handled on the client so a not-yet-updated
backend cannot stop a newer reply.

#297 removed only the user-facing interrupt button; backend barge-in still
drives voice.tts.canceled, so this path is live. Carried over from #245,
which this stacked series replaces -- not reminder-scoped, so none of
#264-#270 picked it up.

* fix(reminder): reset permission-prompt state when the effect unmounts

Code review (PR #271, fennoai): the cleanup only cleared the timer and
unsubscribed onAppActive, not awaitingReturnRef/skippedRef. If a user opened
settings for exact_alarm (or a denied location permission) and logged out
before returning, AppProviders reruns this effect with device=null, but the
stale awaitingReturnRef stayed true. On the next login the new effect's
promptNext() reads that same ref (it's a component-level useRef, not reset
by the effect re-running) and returns immediately every time, and since the
app is already active there's no new onAppActive event left to clear it --
every permission prompt stays disabled until the process restarts.

Reset both refs in the cleanup so a fresh login starts a clean prompt round.

* test(reminder,voice): close Codecov patch-coverage gaps

Codecov flagged 60.79% patch coverage across six files from this branch's
recent commits. Closed each:

- InMemoryLocalScheduleReader.ts: 0% because nothing in the app or tests
  actually uses it -- only re-exported from two barrels, never imported
  or instantiated anywhere in feature/reminder-wiring's own history.
  Deleted the file and its two re-exports instead of testing dead code.
- AlertReminderPresenter.ts: new AlertReminderPresenter.test.ts covers
  every reason-specific message, the title fallback, confirm/snooze
  dispatch, unsubscribe, and hide()'s suppression window. The `?? '...'`
  message fallback is unreachable (MESSAGE_BY_REASON already covers every
  ReminderTriggerReason), so it's istanbul-ignored with a stated reason
  instead of faked with an invalid reason value.
- useReminderPermissionsOnLaunch.ts: added 7 tests for branches the
  existing suite didn't reach -- denied notifications, failed
  openSettings on both the direct-settings and location paths, granted
  location, the bottom settings-redirect branch, a rejected getStatus(),
  and a dismissed (vs declined) dialog. Its own similarly-unreachable
  `prompt == null` branch (all 7 DevicePermission values already have a
  prompt) got the same istanbul-ignore treatment.
- AppProviders.tsx: new AppProviders.test.tsx isolates the
  onPermissionsUpdated -> reminder.rebuild() wiring with a mocked
  useReminderPermissionsOnLaunch, instead of relying on AppRoot.test.tsx's
  much heavier integration setup for one line.
- AssistantContinuousConversationService.ts: dismissReply() had no
  coverage at all before this branch touched one line of it (routing
  through stopPlaybackImmediately()); added a test that drives a reply
  through voice.tts.start/voice.dialogue.reply and asserts dismissReply()
  clears it and stops playback.
- backend agent.py: the interrupted()-with-nothing-ever-spoken branch
  (_last_audio_id is None) wasn't exercised; added
  test_a_barge_in_before_any_reply_started_sends_no_cancellation.

Verified: frontend tsc/eslint/prettier clean, Jest 520/520, Vitest 87/87;
backend ruff/mypy clean, pytest 97.53% coverage.
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.

2 participants