Skip to content
Merged
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
25 changes: 25 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { projectStructuredItemToNativeChat } from '../../../src/shared/structure
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'

vi.mock('react-native', async () => {
const React = await import('react')
Expand Down Expand Up @@ -119,6 +120,30 @@ describe('MobileNativeChatMessage', () => {
}
)

it("names another agent's message and sets it apart from the person's bubble", () => {
const agentMessage: NativeChatMessage = {
...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]),
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
const tree = render(agentMessage)
expect(textIn(tree.root)[0]).toBe('Message from Coder')
// Left-aligned agent prose, not the person's inverted bubble.
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1)
const rows = tree.root.findAll(
(node) => String(node.type) === 'View' && Array.isArray(node.props.style)
)
expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false)
})

it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
Expand Down
19 changes: 17 additions & 2 deletions mobile/src/session/MobileNativeChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { agentMessageAttribution } from './mobile-agent-message-attribution'

function Prose({
block,
Expand Down Expand Up @@ -132,7 +133,9 @@ function MobileNativeChatMessageImpl({
reasoningExpanded?: boolean
onToggleReasoning?: (key: string) => void
}): React.JSX.Element {
const isUser = message.role === 'user'
// Another agent's message is set apart from the person's bubble, left-aligned and named.
const attribution = agentMessageAttribution('Message from', message.from)
const isUser = message.role === 'user' && attribution === null
const isReasoning = message.role === 'reasoning'
// Separate the agent's words from its tool activity: prose renders first, the
// tool calls fold into a collapsible run beneath. The user's own messages get
Expand Down Expand Up @@ -204,7 +207,19 @@ function MobileNativeChatMessageImpl({
{/* A turn with no user bubble carries its bar above its first row. */}
{turnStatusAbove ? statusRow : null}
<View style={[styles.row, isUser && styles.rowUser]}>
<Content onLongPress={onLongPress} style={[styles.content, isUser && styles.userBubble]}>
<Content
onLongPress={onLongPress}
style={[
styles.content,
isUser && styles.userBubble,
attribution !== null && styles.agentMessage
]}
>
{attribution !== null ? (
<Text selectable={INLINE_TEXT_SELECTION} style={styles.agentAttribution}>
{attribution}
Comment on lines +219 to +220

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scale the sender label with fontScale.

When a person enlarges chat text, Prose scales but this new label stays at typography.metaSize. Apply fontScale to the attribution text so the sender remains readable at the selected size.

</Text>
) : null}
{prose.map((block, index) => (
<Prose
key={index}
Expand Down
1 change: 1 addition & 0 deletions mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function card(
paused: false,
needsAttention: false,
caption: null,
attribution: null,
...overrides
}
}
Expand Down
5 changes: 5 additions & 0 deletions mobile/src/session/MobileNativeChatQueuedMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export function MobileNativeChatQueuedMessages({
<ListEnd size={14} color={colors.textMuted} strokeWidth={2} />
)}
<View style={styles.textColumn}>
{card.attribution ? (
<Text style={styles.caption} numberOfLines={1}>
{card.attribution}
Comment on lines +124 to +125

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the full sender attribution readable.

When an agent has a long task name or a card lists multiple senders, numberOfLines={1} truncates the new caption. The mobile card has no hover title to reveal the hidden names. Let this caption wrap, or provide another way to read its full text.

</Text>
) : null}
{/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */}
<Text style={styles.body} numberOfLines={2}>
{card.text}
Expand Down
57 changes: 57 additions & 0 deletions mobile/src/session/mobile-agent-message-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import type { AgentMessageSource } from '../../../src/shared/agent-session-message-source'
import { agentMessageAttribution } from './mobile-agent-message-attribution'
import { mobileQueuedMessageCards } from './mobile-structured-queued-message-cards'

function from(names: (string | null)[]): AgentMessageSource {
return {
kind: 'agent',
senders: names.map((name, index) => ({
party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null },
name
})),
orchestration: null
}
}

describe('who another agent’s message is from, on mobile', () => {
it('names nothing on the person’s own message', () => {
expect(agentMessageAttribution('From', undefined)).toBeNull()
})

it('names each sender, an unnamed one as an agent, and counts the rest', () => {
expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder')
expect(agentMessageAttribution('From', from([]))).toBe('From an agent')
expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1')
})

it('keeps two senders that share a name two, and counts only the senders it leaves out', () => {
expect(agentMessageAttribution('From', from(['Codex', 'Codex']))).toBe('From Codex, Codex')
expect(agentMessageAttribution('From', from([null, null]))).toBe('From an agent, an agent')
expect(agentMessageAttribution('From', from(['Codex', 'Codex', 'Codex', 'Codex']))).toBe(
'From Codex, Codex, Codex +1'
)
})

it('puts the line on the queued card, read through the shared reader', () => {
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: 'You have 1 orchestration message.' }]
}
const cards = mobileQueuedMessageCards(
[
{
messageId: 'agent',
position: 1,
state: 'waiting',
body: { ...body, from: from(['Coder']) }
},
{ messageId: 'person', position: 2, state: 'waiting', body }
],
[],
{ pendingPrompt: false }
)
expect(cards.map((card) => card.attribution)).toEqual(['From Coder', null])
})
})
22 changes: 22 additions & 0 deletions mobile/src/session/mobile-agent-message-attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// The plain line naming who sent another agent's message; not a link on mobile yet.

import {
agentMessageSendersShown,
type AgentMessageSource
} from '../../../src/shared/agent-session-message-source'

const UNNAMED_SENDER = 'an agent'

/** Null for the person's own message. */
export function agentMessageAttribution(
lead: 'From' | 'Message from',
source: AgentMessageSource | undefined
): string | null {
if (!source) {
return null
}
const { shown, more } = agentMessageSendersShown(source)
// One name per sender, repeats included: two senders that share a name are still two.
const names = shown.map((sender) => sender.name ?? UNNAMED_SENDER)
return `${lead} ${names.length > 0 ? names.join(', ') : UNNAMED_SENDER}${more > 0 ? ` +${more}` : ''}`
}
9 changes: 9 additions & 0 deletions mobile/src/session/mobile-native-chat-message-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ export const styles = StyleSheet.create({
// The common cap for an open reasoning block (about ten lines).
maxHeight: 240
},
agentMessage: {
paddingLeft: spacing.md,
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle
},
agentAttribution: {
color: colors.textMuted,
fontSize: typography.metaSize
},
toolRun: {
marginTop: spacing.xs
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ describe('mobileQueuedMessageCards', () => {
state: 'waiting',
paused: false,
needsAttention: false,
caption: null
caption: null,
attribution: null
})
})

Expand Down
7 changes: 6 additions & 1 deletion mobile/src/session/mobile-structured-queued-message-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type AgentSessionQueuedMessage,
type AgentSessionQueuePause
} from '../../../src/shared/agent-session-wire'
import { readAgentMessageSource } from '../../../src/shared/agent-session-message-source'
import { agentMessageAttribution } from './mobile-agent-message-attribution'

export type MobileQueuedMessageCard = {
messageId: string
Expand All @@ -24,6 +26,8 @@ export type MobileQueuedMessageCard = {
needsAttention: boolean
/** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */
caption: string | null
/** "From <name>" on another agent's card; null on the person's. */
attribution: string | null
}

function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string {
Expand Down Expand Up @@ -136,7 +140,8 @@ export function mobileQueuedMessageCards(
needsAttention:
draft.state === 'returned' ||
(paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED),
caption
caption,
attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from))
})
if (draft.state === 'returned') {
behindReturned = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const CARD = {
state: 'waiting' as const,
paused: false,
needsAttention: false,
caption: null
caption: null,
attribution: null
}

function Slot({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,8 @@ describe('mobile structured queued messages', () => {
state: 'waiting',
paused: false,
needsAttention: false,
caption: null
caption: null,
attribution: null
}
])
// A frame without the field leaves the list alone; null empties it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
type QueuedMessageHoldReason,
type QueuedMessageRow
} from './queued-message-table'
import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source'
import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo'
import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions'
import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention'
Expand Down Expand Up @@ -117,7 +116,6 @@ export class JournalQueuedMessages {
fingerprint: string
hostInstance: string
carriedFrom?: string
source: AgentSessionMessageSource
holdReason?: QueuedMessageHoldReason
},
receipt?: JournalOperationReceipt
Expand Down
30 changes: 30 additions & 0 deletions src/main/native-chat/agent-session-journal/journal-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import {
applyJournalRow,
createJournalReducerState,
Expand Down Expand Up @@ -451,6 +452,35 @@ describe('submission and dispatch state machine', () => {
])
})

it("folds the echo of another agent's message into its bubble, which keeps the sender", () => {
const echoed = userText('You have 1 orchestration message.')
const from = {
kind: 'agent' as const,
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
const state = fold([
{
kind: 'submission',
clientMessageId: 'agent-send',
// Stored as the send path stores it: the sender is outside the fingerprint.
payloadFingerprint: agentSessionSendBodyFingerprint('session-1', { ...echoed, from }),
providerHandle: { kind: 'codex', threadId: 'thread-1' },
body: { ...echoed, from },
...base(1)
},
{ kind: 'item', itemId: 'codex:thread-1:turn-1:0', revision: 1, body: echoed, ...base(2) }
])
expect(renderJournalState(state).items).toMatchObject([
{ itemId: agentJournalSubmissionKey('agent-send'), body: { from } }
])
})

it.each([5, 10])(
'reconciles %i rapid sends across an interleaved cancel when Codex reuses the root turn',
(count) => {
Expand Down
8 changes: 2 additions & 6 deletions src/main/native-chat/agent-session-journal/journal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import { JournalDerivedTurnScope } from './journal-derived-turn-scope'
import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold'
import { journalItemRevisionIsStale } from './journal-item-revision'
Expand Down Expand Up @@ -202,11 +202,7 @@ export function journalEchoClaimant(
if (!body || !isProviderUserMessageEcho(itemId, body)) {
return null
}
const fingerprint = structuredAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: state.sessionId,
fields: { body }
})
const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, body)
// Exact payload plus queue order preserves repeated identical sends one-for-one.
// A submission an echo may not claim is one that says the message never reached
// the provider, so an item resembling it is somebody else's. That is `rejected`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export function buildJournalSubmissionRow(input: {
...(input.handoverRecorded ? { handoverRecorded: true } : {}),
...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}),
...(input.origin !== undefined ? { origin: input.origin } : {}),
// The kind only: a caller's full source carries senders that stay host-only.
// The kind only; who the senders are is on the message body.
...(input.source !== undefined ? { source: { kind: input.source.kind } } : {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission
messageId: draftId,
body: BODY,
fingerprint: 'fp',
hostInstance: 'p',
source: { kind: 'user' }
hostInstance: 'p'
})
await journal.appendSubmission(
{ clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 },
Expand Down Expand Up @@ -101,8 +100,7 @@ describe('the submission names the queued draft it hands off', () => {
messageId: 'draft-1',
body: BODY,
fingerprint: 'fp',
hostInstance: 'p',
source: { kind: 'user' }
hostInstance: 'p'
})
await expect(
journal.appendSubmission(
Expand Down
Loading
Loading