Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/app/src/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parse voice delegations in queued prompt rows

When an iOS voice follow-up arrives while the Session is busy, it can remain in SessionQueuePanel; that panel obtains its label through queuedPromptText (queue.ts:389-393), which returns the raw payload.text when the iOS message has no displayText metadata. Because voice parsing is applied only to delivered timeline rows here, the queued row exposes the full delegation header, JSON, and transcript instead of the framing or instruction until delivery. Apply the same presentation parsing to queued prompts.

AGENTS.md reference: AGENTS.md:L184-L184

Useful? React with 👍 / 👎.


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,
Expand Down
63 changes: 63 additions & 0 deletions packages/app/src/session/timeline/voice-delegation.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
33 changes: 33 additions & 0 deletions packages/app/src/session/timeline/voice-delegation.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
35 changes: 34 additions & 1 deletion packages/session-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/session-ui/src/message/current-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function SessionUserMessage(props: {
displayText?: string
comments?: SessionUserComment[]
references?: SessionUserAttachmentReference[]
voice?: boolean
historicalAgent: string
historicalModel: SessionMessageAssistant["model"]
actions?: SessionUserActions
Expand All @@ -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}
Expand Down
75 changes: 61 additions & 14 deletions packages/session-ui/src/message/message-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -389,7 +393,14 @@ export function CurrentUserMessageDisplay(props: {
</Show>
}
>
<div data-slot="user-message-body">
<div data-slot="user-message-body" data-voice={props.voice ? "true" : undefined}>
<Show when={props.voice}>
<div data-slot="user-message-voice" role="img" aria-label={i18n.t("ui.message.voice")}>
<Tooltip appearance="compact" value={i18n.t("ui.message.voice")} placement="top" gutter={4}>
<Icon name="waveform" size="small" />
</Tooltip>
</div>
</Show>
<div data-slot="user-message-text" dir="auto" data-comments={comments().length > 0 ? "true" : undefined}>
<CurrentHighlightedText text={props.text} files={inlineFiles()} agents={agents()} />
<Show when={comments().length > 0}>
Expand Down Expand Up @@ -453,17 +464,53 @@ export function CurrentUserMessageDisplay(props: {
aria-label={i18n.t("ui.message.revertMessage")}
/>
</Show>
<Show when={props.text}>
<MessageActionButton
icon={state.copied ? "check" : "copy"}
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
onMouseDown={(event) => 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. */}
<Show
when={props.voice}
fallback={
<Show when={props.text}>
<MessageActionButton
icon={state.copied ? "check" : "copy"}
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
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")}
/>
</Show>
}
>
<Menu.Context modal={false}>
<Tooltip
appearance="compact"
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="top"
gutter={4}
>
<Menu.Context.Trigger
as={IconButton}
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
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")}
/>
</Tooltip>
<Menu.Context.Portal>
<Menu.Context.Content>
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
<Menu.Item onSelect={() => void copy(props.message.text)}>
{i18n.t("ui.message.copyFullMessage")}
</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
</div>
</Show>
Expand Down
47 changes: 43 additions & 4 deletions packages/session-ui/src/storybook/current-session-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,18 @@ function runningTool(input: {
output?: string
metadata?: Record<string, JsonValue>
}): SessionMessageAssistantTool {
const metadata = { ...input.metadata }

if (input.output !== undefined) metadata.output = input.output

return {
type: "tool",
id: input.id,
name: input.name,
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 },
}
Expand Down Expand Up @@ -1117,6 +1118,44 @@ export const attachmentsAndCommentsPresentation = {
},
} satisfies Record<string, SessionUserPresentation>

// 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<string, SessionUserPresentation>

export const revertDocument = document([
user("msg_user_revert_base", "Add a compact Session summary.", 90_000),
assistant({
Expand Down
3 changes: 3 additions & 0 deletions packages/session-ui/src/timeline/session-timeline-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading