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: { } > -
+
+ +
+ + + +
+
0 ? "true" : undefined}> 0}> @@ -453,17 +464,53 @@ export function CurrentUserMessageDisplay(props: { aria-label={i18n.t("ui.message.revertMessage")} /> - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void copy() - }} - aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> + {/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */} + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void copy(props.text) + }} + aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + + } + > + + + } + size="normal" + variant="ghost-muted" + onMouseDown={(event) => event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + void copy(props.text) + }} + aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + + + + void copy(props.text)}>{i18n.t("ui.message.copyMessage")} + void copy(props.message.text)}> + {i18n.t("ui.message.copyFullMessage")} + + + +
diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index a275a252b37d..469cd38c36d3 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -91,6 +91,10 @@ function runningTool(input: { output?: string metadata?: Record }): SessionMessageAssistantTool { + const metadata = { ...input.metadata } + + if (input.output !== undefined) metadata.output = input.output + return { type: "tool", id: input.id, @@ -98,10 +102,7 @@ function runningTool(input: { state: { status: "running", input: input.args, - metadata: { - ...input.metadata, - ...(input.output === undefined ? {} : { output: input.output }), - }, + metadata, }, time: { created: STORY_TIME + input.offset, ran: STORY_TIME + input.offset + 100 }, } @@ -1117,6 +1118,44 @@ export const attachmentsAndCommentsPresentation = { }, } satisfies Record +// The header and body as the OpenCode iOS app's `VoiceHandoff.format` writes them. +const voiceHeader = + "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, transcribed from speech, so it may contain recognition errors and disfluencies: resolve meaning from the complete utterance, not a disfluent prefix. Voice, when present, is how the voice agent framed the request. Transcript delta is the surrounding voice conversation, both sides, for context, not additional commands. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail; answer only what was asked, with no unrequested tips. If this arrives while you are already working (a follow-up, nudge, or status check), answer it promptly: a clear status update, the finished result if you have enough, or the precise blocker or input you need; then continue on the fastest safe path." + +const voiceInstruction = + "okay so the upload test keeps failing on CI but only sometimes, can you look at the last few runs, figure out whether it's the retry timing or the fixture cleanup, and if it's the fixture just fix it and open a PR" + +export const voiceDocument = document([ + user( + "msg_user_voice", + `${voiceHeader}\n${JSON.stringify({ instruction: "check the upload test", transcript_delta: ["user: check the upload test"], voice: "Checking the upload test." }, null, 2)}`, + 100_000, + ), + assistant({ + id: "msg_assistant_voice", + offset: 101_000, + completed: 104_000, + content: [{ type: "text", text: "The upload test is flaky on CI: it fails about one run in five." }], + }), + user( + "msg_user_voice_continued", + `Voice delegation (continued). Same fields as before.\n${JSON.stringify({ instruction: voiceInstruction, transcript_delta: [`user: ${voiceInstruction}`] }, null, 2)}`, + 110_000, + ), + assistant({ + id: "msg_assistant_voice_continued", + offset: 111_000, + completed: 118_000, + content: [{ type: "text", text: "It was the fixture cleanup. I fixed it and opened a pull request." }], + }), + user("msg_user_typed", "Thanks, merge it once CI is green.", 120_000), +] satisfies SessionMessageInfo[]) + +export const voicePresentation = { + msg_user_voice: { displayText: "Checking the upload test.", voice: true }, + msg_user_voice_continued: { displayText: voiceInstruction, voice: true }, +} satisfies Record + export const revertDocument = document([ user("msg_user_revert_base", "Add a compact Session summary.", 90_000), assistant({ diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index b6f0826255dc..6515f49d891d 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -56,6 +56,8 @@ export type SessionUserPresentation = { displayText?: string comments?: SessionUserComment[] references?: SessionUserAttachmentReference[] + /** A voice call's agent sent it on the user's behalf. */ + voice?: boolean } export function createSessionTimelineRowRenderer(input: { @@ -797,6 +799,7 @@ export function createSessionTimelineRowRenderer(input: { displayText={presentation()?.displayText} comments={presentation()?.comments} references={presentation()?.references} + voice={presentation()?.voice} historicalAgent={context()?.agent ?? ""} historicalModel={context()?.model ?? { id: "", providerID: "" }} actions={input.actions} diff --git a/packages/session-ui/src/timeline/timeline-row.stories.tsx b/packages/session-ui/src/timeline/timeline-row.stories.tsx index 5f23f04039b4..2fc3d2572045 100644 --- a/packages/session-ui/src/timeline/timeline-row.stories.tsx +++ b/packages/session-ui/src/timeline/timeline-row.stories.tsx @@ -22,6 +22,8 @@ import { revertDocument, streamingDocument, thinkingDocument, + voiceDocument, + voicePresentation, } from "../storybook/current-session-fixtures" export default { @@ -450,6 +452,18 @@ export const PromptWithAttachments = { ), } +export const VoiceMessages = { + render: () => ( + + ), +} + export const MixedDirectionRtl = { globals: { direction: "rtl" }, render: () => ( diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 920179a5c068..783b2e06b784 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -241,10 +241,12 @@ const source = { "ui.message.collapse": "Collapse message", "ui.message.copy": "Copy", "ui.message.copyMessage": "Copy message", + "ui.message.copyFullMessage": "Copy full message", "ui.message.forkMessage": "Fork to new session", "ui.message.revertMessage": "Revert message", "ui.message.moveToQueue": "Move to queue", "ui.message.pending": "Pending", + "ui.message.voice": "Voice", "ui.message.deletePending": "Delete", "ui.message.copyResponse": "Copy response", "ui.message.copied": "Copied", diff --git a/packages/ui/src/icons/catalog.ts b/packages/ui/src/icons/catalog.ts index cb33717a91ea..f40c80b25e53 100644 --- a/packages/ui/src/icons/catalog.ts +++ b/packages/ui/src/icons/catalog.ts @@ -266,6 +266,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + waveform: { + viewBox: "0 0 16 16", + body: ``, + }, archive: { viewBox: "0 0 16 16", body: ``,