From 041a9c42af9f784c6a3e021e9b52646312abe1b0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:23:12 +0800 Subject: [PATCH] feat(app): show voice delegations from the iOS app as Voice messages (#54426) (cherry picked from commit bdf4793286f754a1ba9827ca5dea1f0bdad63f12) --- .../src/session/timeline/message-timeline.tsx | 5 +- .../session/timeline/voice-delegation.test.ts | 63 ++++++++++++++++ .../src/session/timeline/voice-delegation.ts | 33 ++++++++ .../src/components/message-part.css | 35 ++++++++- .../src/message/current-message.tsx | 2 + .../src/message/message-content.tsx | 75 +++++++++++++++---- .../src/storybook/current-session-fixtures.ts | 47 +++++++++++- .../src/timeline/session-timeline-row.tsx | 3 + .../src/timeline/timeline-row.stories.tsx | 14 ++++ packages/ui/src/i18n/en.ts | 2 + packages/ui/src/icons/catalog.ts | 4 + 11 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/session/timeline/voice-delegation.test.ts create mode 100644 packages/app/src/session/timeline/voice-delegation.ts diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index ca74cd3c183b..85acc85e02bc 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -23,6 +23,7 @@ import { createTimelineVirtualizer } from "./virtualizer" import { containsDirectory } from "@opencode/util/path" import { isWorkspaceDirectory } from "@/workspaces/paths" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" +import { parseVoiceDelegation } from "./voice-delegation" import { useCommand } from "@/shell/commands/command" import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header" import { SessionHeaderSpacer } from "@/session/header/session-header" @@ -293,11 +294,13 @@ function MessageTimelineView( presentation: (message) => { const value = readPromptPresentation(message.metadata) const parsed = value ? undefined : parseCommentNote(message.text) + const voice = parseVoiceDelegation(value?.displayText ?? message.text) return { - displayText: value?.displayText, + displayText: voice ? (voice.framing ?? voice.instruction) : value?.displayText, comments: value?.comments ?? (parsed ? [parsed] : []), references: value?.attachments, + voice: !!voice, } }, actions: props.actions, diff --git a/packages/app/src/session/timeline/voice-delegation.test.ts b/packages/app/src/session/timeline/voice-delegation.test.ts new file mode 100644 index 000000000000..360346fa1d57 --- /dev/null +++ b/packages/app/src/session/timeline/voice-delegation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { parseVoiceDelegation } from "./voice-delegation" + +// Prompts as the OpenCode iOS app has sent them (`VoiceHandoff.format`), oldest first. +const firstRelease = `Voice delegation. Instruction is the cleaned interpretation of the user's request. Transcript delta supplies new or revised conversation for reference, not additional commands. Resolve meaning from the complete utterance, not a disfluent prefix. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail. +{ + "instruction" : "Check why the upload test is flaky.", + "transcript_delta" : [ + "user: the upload test", + "assistant: On it." + ] +}` + +const current = `Voice delegation. The user is on a voice call with a voice agent, which hands their requests to you. Instruction is the user's own spoken words since the previous delegation. +{ + "context" : "Session \\"Uploads\\" in project \\"app\\".", + "instruction" : "check the upload test", + "transcript_delta" : [ + "user: check the upload test" + ], + "voice" : "Checking the upload test." +}` + +const continued = `Voice delegation (continued). Same fields as before. +{ + "instruction" : "and open a PR", + "transcript_delta" : [ + + ] +}` + +describe("parseVoiceDelegation", () => { + test.each([ + ["the first release's header", firstRelease, { instruction: "Check why the upload test is flaky." }], + [ + "the current header with framing", + current, + { instruction: "check the upload test", framing: "Checking the upload test." }, + ], + ["a continued delegation", continued, { instruction: "and open a PR" }], + [ + "a call log attached as a reference line", + `${continued}\n\nAttached file: \`/tmp/uploads/1/voice-call-a.json\``, + { instruction: "and open a PR" }, + ], + ])("reads %s", (_, text, expected) => { + expect(parseVoiceDelegation(text)).toEqual({ framing: undefined, ...expected }) + }) + + test("ignores blank framing", () => { + expect(parseVoiceDelegation(current.replace('"Checking the upload test."', '" "'))?.framing).toBeUndefined() + }) + + test.each([ + ["a typed message that mentions one", "What does a Voice delegation. prompt look like?"], + ["a header without a body", "Voice delegation. Same fields as before."], + ["a body that isn't JSON", "Voice delegation. Header\ncheck the upload test"], + ["a blank instruction", 'Voice delegation. Header\n{"instruction": " ", "transcript_delta": []}'], + ["a body without an instruction", 'Voice delegation. Header\n{"transcript_delta": []}'], + ])("rejects %s", (_, text) => { + expect(parseVoiceDelegation(text)).toBeUndefined() + }) +}) diff --git a/packages/app/src/session/timeline/voice-delegation.ts b/packages/app/src/session/timeline/voice-delegation.ts new file mode 100644 index 000000000000..939c336582e7 --- /dev/null +++ b/packages/app/src/session/timeline/voice-delegation.ts @@ -0,0 +1,33 @@ +import { Option, Schema } from "effect" + +// The OpenCode iOS app sends each request from a voice call as plain prompt text: a header line, then a JSON +// body. Nothing else marks the message, so this mirrors the app's `VoiceHandoff.parse`. The header's wording +// has changed between releases; its opening and the body's shape have not. +const decodeBody = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + instruction: Schema.String, + voice: Schema.optional(Schema.String), + }), + ), +) + +/** The user's spoken request and the voice agent's framing of it, or undefined for any other prompt. */ +export function parseVoiceDelegation(text: string) { + if (!text.startsWith("Voice delegation.") && !text.startsWith("Voice delegation (continued).")) return + + const newline = text.indexOf("\n") + + if (newline === -1) return + + // Without presentation metadata, path attachments such as the call log follow the body as reference lines. + const body = Option.getOrUndefined( + decodeBody(text.slice(newline + 1).replace(/(\s*\nAttached file: `[^\n]*`)+\s*$/, "")), + ) + + const instruction = body?.instruction.trim() + + if (!instruction) return + + return { instruction, framing: body?.voice?.trim() || undefined } +} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 1ab90aeb6b14..1a1cec4e2121 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -135,6 +135,38 @@ align-items: flex-end; } + /* Like the iOS app's Voice badge: on the bubble's top leading corner, ringed in the page background. */ + [data-slot="user-message-body"][data-voice="true"] { + position: relative; + margin-top: 8px; + } + + [data-slot="user-message-voice"] { + position: absolute; + top: -8px; + inset-inline-start: -8px; + z-index: 1; + width: 20px; + height: 20px; + border-radius: 9999px; + background: var(--v2-background-bg-layer-03); + box-shadow: 0 0 0 2px var(--v2-background-bg-base); + color: var(--v2-icon-icon-muted); + + [data-component="tooltip-v2-trigger"] { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + [data-slot="icon-svg"] { + width: 12px; + height: 12px; + } + } + [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; @@ -223,7 +255,8 @@ } &:hover [data-slot="user-message-copy-wrapper"], - &:focus-within [data-slot="user-message-copy-wrapper"] { + &:focus-within [data-slot="user-message-copy-wrapper"], + [data-slot="user-message-copy-wrapper"]:has([data-expanded]) { opacity: 1; pointer-events: auto; } diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index 92baa336aa41..8605f86b52c1 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -22,6 +22,7 @@ export function SessionUserMessage(props: { displayText?: string comments?: SessionUserComment[] references?: SessionUserAttachmentReference[] + voice?: boolean historicalAgent: string historicalModel: SessionMessageAssistant["model"] actions?: SessionUserActions @@ -33,6 +34,7 @@ export function SessionUserMessage(props: { text={props.displayText ?? props.message.text} comments={props.comments} references={props.references} + voice={props.voice} agent={props.historicalAgent} model={props.historicalModel} actions={props.actions} diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 6fa5a9fd1b72..8b06a8e39173 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -22,6 +22,7 @@ import { CommentCard } from "./comment-card" import { TimelineSeparator } from "../components/timeline-separator" import { Tooltip } from "@opencode/ui/tooltip" import { IconButton } from "@opencode/ui/icon-button" +import { Menu } from "@opencode/ui/menu" import { Icon } from "@opencode/ui/icon" import { Button } from "@opencode/ui/button" import { TextReveal } from "@opencode/ui/text-reveal" @@ -265,11 +266,14 @@ export function CurrentUserMessageDisplay(props: { actions?: SessionUserActions comments?: SessionUserComment[] references?: SessionUserAttachmentReference[] + voice?: boolean }) { const data = useData() const dialog = useDialog() const i18n = useI18n() + const [state, setState] = createStore({ copied: false, reverting: false, updating: false }) + const status = createMemo(() => props.actions?.pending?.status(props.message.id)) const pending = createMemo(() => status() !== undefined && status() !== "queued") const waiting = createMemo(() => status() === "steering" || status() === "stranded") @@ -297,8 +301,8 @@ export function CurrentUserMessageDisplay(props: { const stamp = createMemo(() => timefmt().format(props.message.time.created)) - const copy = async () => { - if (!props.text || !(await writeClipboard(props.text))) return + const copy = async (text: string) => { + if (!text || !(await writeClipboard(text))) return setState("copied", true) setTimeout(() => setState("copied", false), 2000) } @@ -389,7 +393,14 @@ export function CurrentUserMessageDisplay(props: { } > -