diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts index 91820bfa09..87a9317967 100644 --- a/apps/mobile/src/components/agents/message-bubble.test.ts +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -179,3 +179,75 @@ describe('MessageBubble regressions', () => { expect(findText(dequeuedTree, t => t === 'Queued')).toBe(false); }); }); + +function pressableProps(node: unknown): Record | null { + if (node == null || typeof node !== 'object') { + return null; + } + const element = node as { type?: unknown; props?: Record }; + if (element.type === 'Pressable' && element.props) { + return element.props; + } + const children = element.props?.children; + if (Array.isArray(children)) { + for (const child of children) { + const found = pressableProps(child); + if (found) { + return found; + } + } + } else if (children && typeof children === 'object') { + return pressableProps(children); + } + return null; +} + +describe('MessageBubble long-press details', () => { + it('uses the details accessibility hint on user messages', async () => { + const tree = await renderBubble(userMessage('m-hint-user')); + const props = pressableProps(tree); + expect(props?.accessibilityHint).toBe('Long press for message details'); + expect(props?.accessibilityActions).toEqual([{ name: 'copy', label: 'Copy message' }]); + }); + + it('uses the details accessibility hint on assistant messages', async () => { + const base = userMessage('m-hint-asst'); + const assistant: StoredMessage = { + info: { + id: base.info.id, + sessionID: base.info.sessionID, + role: 'assistant', + time: { created: base.info.time.created }, + parentID: 'm0', + modelID: 'anthropic/claude-sonnet-4', + providerID: 'kilo', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [], + }; + const tree = await renderBubble(assistant); + const props = pressableProps(tree); + expect(props?.accessibilityHint).toBe('Long press for message details'); + }); + + it('invokes onLongPressDetails on long-press, not copyMessage', async () => { + const onLongPressDetails = vi.fn((..._args: unknown[]) => { + // void-returning callback matching MessageBubble's prop type + }); + const { MessageBubble } = await import('./message-bubble'); + const message = userMessage('m-long'); + // eslint-disable-next-line new-cap + const tree = MessageBubble({ message, onLongPressDetails }); + const props = pressableProps(tree); + expect(props).not.toBeNull(); + const handler = props === null ? undefined : props.onLongPress; + expect(typeof handler).toBe('function'); + const invoke = handler as (() => void) | undefined; + invoke?.(); + expect(onLongPressDetails).toHaveBeenCalledWith(message); + }); +}); diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index 6919fc7406..61bbd66d94 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -24,15 +24,12 @@ type MessageBubbleProps = { onOpenChildSession?: OpenChildSession; /** Per-user-message delivery state. v1 surfaces only a "Queued" badge. */ deliveryState?: MessageDeliveryState; - /** - * Subtle model label for an assistant message, precomputed by the parent - * via `computeMessageModelLabels`. Only the assistant branch renders it - * (and only when truthy); the user branch and the unlabelled - * same-model follow-ups render nothing. - */ - modelLabel?: string; + /** Opens the message-details sheet; long-press never triggers the copy ActionSheet. */ + onLongPressDetails?: (message: StoredMessage) => void; }; +const DETAILS_HINT = 'Long press for message details'; + export function MessageBubble({ message, isLastAssistantMessage, @@ -41,19 +38,18 @@ export function MessageBubble({ defaultReasoningExpanded, onOpenChildSession, deliveryState, - modelLabel, + onLongPressDetails, }: Readonly) { const isUser = message.info.role === 'user'; const { copyMessage } = useMessageCopy(); const colors = useThemeColors(); const handleLongPress = () => { - void copyMessage(message); + onLongPressDetails?.(message); }; - // Long-press is an accelerator; expose the same "copy" action to - // accessibility tooling (VoiceOver/TalkBack rotor) since a long-press - // gesture isn't reliably discoverable there. + // Long-press opens details; keep the VoiceOver/TalkBack rotor "copy" action + // on the bubble so a11y tooling still reaches the existing ActionSheet path. const copyAccessibilityActions = [{ name: 'copy', label: 'Copy message' }]; const handleAccessibilityAction = (event: AccessibilityActionEvent) => { if (event.nativeEvent.actionName === 'copy') { @@ -85,7 +81,7 @@ export function MessageBubble({ className="px-4 py-1" accessibilityRole="text" accessibilityLabel="User message" - accessibilityHint="Long press to copy message text" + accessibilityHint={DETAILS_HINT} accessibilityActions={copyAccessibilityActions} onAccessibilityAction={handleAccessibilityAction} > @@ -124,7 +120,7 @@ export function MessageBubble({ onLongPress={handleLongPress} accessibilityRole="text" accessibilityLabel="Assistant message" - accessibilityHint="Long press to copy message text" + accessibilityHint={DETAILS_HINT} accessibilityActions={copyAccessibilityActions} onAccessibilityAction={handleAccessibilityAction} > @@ -139,15 +135,6 @@ export function MessageBubble({ onOpenChildSession={onOpenChildSession} /> ))} - {modelLabel ? ( - - {modelLabel} - - ) : null} ); diff --git a/apps/mobile/src/components/agents/message-details-content.ts b/apps/mobile/src/components/agents/message-details-content.ts new file mode 100644 index 0000000000..c27a4c05ba --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-content.ts @@ -0,0 +1,129 @@ +import { type StoredMessage } from 'cloud-agent-sdk'; + +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { collectCopyableText } from './collect-copyable-text'; +import { formatCost } from './context-usage-display'; +import { resolveMessageDisplayModel } from './message-model-label'; +import { friendlyModelName } from './session-model-display'; + +type MessageDetailsTokenRow = { + label: string; + value: number; +}; + +type MessageDetailsContent = { + roleLabel: string; + sentTimeLabel: string | null; + modelLabel: string | null; + costLabel: string | null; + tokenRows: MessageDetailsTokenRow[] | null; + copyableText: string | null; +}; + +const SENT_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * Pure projection of a StoredMessage into the details-sheet fields. + * Unit-tested for happy / empty visibility rules; the sheet component + * only renders this shape. + */ +export function getMessageDetailsContent( + message: StoredMessage, + modelOptions: SessionModelOption[] +): MessageDetailsContent { + const roleLabel = message.info.role === 'user' ? 'User' : 'Assistant'; + const sentTimeLabel = formatMessageSentTime(message.info.time.created); + const copyable = collectCopyableText(message); + const copyableText = copyable.length > 0 ? copyable : null; + + if (message.info.role !== 'assistant') { + return { + roleLabel, + sentTimeLabel, + modelLabel: null, + costLabel: null, + tokenRows: null, + copyableText, + }; + } + + const resolved = resolveMessageDisplayModel(message); + const modelLabel = resolved + ? friendlyModelName(resolved.providerID, resolved.modelID, modelOptions) + : null; + + const usage = getAssistantUsage(message); + const showUsage = usage !== null && !isZeroUsage(usage); + + return { + roleLabel, + sentTimeLabel, + modelLabel, + costLabel: showUsage ? formatCost(usage.cost) : null, + tokenRows: showUsage + ? [ + { label: 'Input', value: usage.input }, + { label: 'Output', value: usage.output }, + { label: 'Reasoning', value: usage.reasoning }, + { label: 'Cache read', value: usage.cacheRead }, + { label: 'Cache write', value: usage.cacheWrite }, + { label: 'Total', value: usage.total }, + ] + : null, + copyableText, + }; +} + +type AssistantUsage = { + cost: number; + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + total: number; +}; + +function getAssistantUsage(message: StoredMessage): AssistantUsage | null { + if (message.info.role !== 'assistant') { + return null; + } + const { cost, tokens } = message.info; + const input = tokens.input; + const output = tokens.output; + const reasoning = tokens.reasoning; + const cacheRead = tokens.cache.read; + const cacheWrite = tokens.cache.write; + return { + cost, + input, + output, + reasoning, + cacheRead, + cacheWrite, + total: input + output + reasoning + cacheRead + cacheWrite, + }; +} + +function isZeroUsage(usage: AssistantUsage): boolean { + return ( + usage.cost === 0 && + usage.input === 0 && + usage.output === 0 && + usage.reasoning === 0 && + usage.cacheRead === 0 && + usage.cacheWrite === 0 + ); +} + +/** Format an epoch-ms created timestamp; null when absent/invalid. */ +export function formatMessageSentTime(created: number | undefined | null): string | null { + if (created === undefined || created === null || !Number.isFinite(created) || created <= 0) { + return null; + } + return SENT_TIME_FORMATTER.format(new Date(created)); +} diff --git a/apps/mobile/src/components/agents/message-details-copy.ts b/apps/mobile/src/components/agents/message-details-copy.ts new file mode 100644 index 0000000000..9edeed222f --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-copy.ts @@ -0,0 +1,12 @@ +import { performCopy } from './use-message-copy'; + +/** + * Details-sheet Copy path: immediate shared `performCopy`, no ActionSheet. + * Kept free of RN UI so unit tests can pin the wiring. + */ +export function handleMessageDetailsCopy(copyableText: string | null | undefined): void { + if (!copyableText) { + return; + } + void performCopy(copyableText); +} diff --git a/apps/mobile/src/components/agents/message-details-sheet.test.ts b/apps/mobile/src/components/agents/message-details-sheet.test.ts new file mode 100644 index 0000000000..554c15ebb6 --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-sheet.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type AssistantMessage, + type Part, + type StepFinishPart, + type StoredMessage, + type UserMessage, +} from 'cloud-agent-sdk'; + +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { formatMessageSentTime, getMessageDetailsContent } from './message-details-content'; + +const performCopyMock = vi.fn().mockResolvedValue(undefined); +const showActionSheetWithOptions = vi.fn(); + +vi.mock('./use-message-copy', () => ({ + performCopy: (...args: unknown[]) => performCopyMock(...args), +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + ActionSheetIOS: { + showActionSheetWithOptions: (...args: unknown[]) => showActionSheetWithOptions(...args), + }, +})); + +function assistantInfo(overrides: Partial = {}): AssistantMessage { + return { + id: 'msg-1', + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1_700_000_000_000 }, + parentID: 'msg-0', + modelID: 'claude-sonnet-4', + providerID: 'kilo', + mode: 'code', + agent: 'test', + path: { cwd: '/', root: '/' }, + cost: 0.0123, + tokens: { + input: 100, + output: 50, + reasoning: 10, + cache: { read: 5, write: 2 }, + }, + ...overrides, + }; +} + +function userInfo(overrides: Partial = {}): UserMessage { + return { + id: 'u-1', + sessionID: 'ses-1', + role: 'user', + time: { created: 1_700_000_000_000 }, + agent: 'test', + model: { providerID: 'kilo', modelID: 'claude-sonnet-4' }, + ...overrides, + }; +} + +function textPart(text: string, id = 'p-text'): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'text', + text, + }; +} + +function stepFinish(overrides: Partial = {}): StepFinishPart { + return { + id: 'p-finish', + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'step-finish', + reason: 'stop', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + }; +} + +function stepFinishWithRouted( + routed: { providerID: string; modelID: string }, + overrides: Partial = {} +): StepFinishPart { + return Object.assign(stepFinish(overrides), { model: routed }) as StepFinishPart; +} + +function storedMessage(info: AssistantMessage | UserMessage, parts: Part[] = []): StoredMessage { + return { info, parts }; +} + +const catalogOptions: SessionModelOption[] = [ + { + id: 'anthropic/claude-sonnet-4', + name: 'Claude Sonnet 4', + displayId: 'anthropic/claude-sonnet-4', + variants: [], + isPreferred: false, + showGatewayMetadata: false, + modelRef: { providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }, + provider: { id: 'kilo', name: 'Kilo' }, + }, + { + id: 'kilo-auto/efficient', + name: 'Auto Efficient', + displayId: 'kilo-auto/efficient', + variants: [], + isPreferred: false, + showGatewayMetadata: true, + provider: { id: 'kilo', name: 'Kilo' }, + }, +]; + +describe('formatMessageSentTime', () => { + it('formats a finite positive epoch ms timestamp', () => { + const label = formatMessageSentTime(1_700_000_000_000); + expect(label).not.toBeNull(); + expect(typeof label).toBe('string'); + expect((label ?? '').length).toBeGreaterThan(0); + }); + + it('returns null when the timestamp is absent or invalid', () => { + expect(formatMessageSentTime(undefined)).toBeNull(); + expect(formatMessageSentTime(null)).toBeNull(); + expect(formatMessageSentTime(0)).toBeNull(); + expect(formatMessageSentTime(Number.NaN)).toBeNull(); + expect(formatMessageSentTime(-1)).toBeNull(); + }); +}); + +describe('getMessageDetailsContent — happy', () => { + it('projects a user message with role, sent time, and copy text (no model/cost)', () => { + const message = storedMessage(userInfo(), [textPart('hello world')]); + const content = getMessageDetailsContent(message, catalogOptions); + + expect(content.roleLabel).toBe('User'); + expect(content.sentTimeLabel).not.toBeNull(); + expect(content.copyableText).toBe('hello world'); + expect(content.modelLabel).toBeNull(); + expect(content.costLabel).toBeNull(); + expect(content.tokenRows).toBeNull(); + }); + + it('projects an assistant message with model, cost, and token rows', () => { + const info = assistantInfo({ + providerID: 'kilo', + modelID: 'kilo-auto/efficient', + cost: 0.0123, + tokens: { + input: 100, + output: 50, + reasoning: 10, + cache: { read: 5, write: 2 }, + }, + }); + const routed = stepFinishWithRouted({ + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + }); + const message = storedMessage(info, [textPart('assistant reply'), routed]); + const content = getMessageDetailsContent(message, catalogOptions); + + expect(content.roleLabel).toBe('Assistant'); + expect(content.sentTimeLabel).not.toBeNull(); + expect(content.copyableText).toBe('assistant reply'); + // Routed stamp preferred; catalog-friendly name. + expect(content.modelLabel).toBe('Claude Sonnet 4'); + expect(content.costLabel).toBe('$0.0123'); + expect(content.tokenRows).toEqual([ + { label: 'Input', value: 100 }, + { label: 'Output', value: 50 }, + { label: 'Reasoning', value: 10 }, + { label: 'Cache read', value: 5 }, + { label: 'Cache write', value: 2 }, + { label: 'Total', value: 167 }, + ]); + }); + + it('shows the info-level auto model when no routed stamp exists (explicit detail)', () => { + const info = assistantInfo({ + providerID: 'kilo', + modelID: 'kilo-auto/efficient', + cost: 0.001, + tokens: { + input: 1, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }); + const message = storedMessage(info, [textPart('hi')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.modelLabel).toBe('Auto Efficient'); + }); +}); + +describe('getMessageDetailsContent — empty', () => { + it('omits the model row when the assistant model is unresolvable', () => { + const info = assistantInfo({ + providerID: '', + modelID: '', + cost: 0.01, + tokens: { + input: 10, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }); + const message = storedMessage(info, [textPart('no model')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.roleLabel).toBe('Assistant'); + expect(content.modelLabel).toBeNull(); + expect(content.costLabel).not.toBeNull(); + expect(content.tokenRows).not.toBeNull(); + }); + + it('omits the cost/tokens block when cost and all five token values are zero', () => { + const info = assistantInfo({ + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }); + const message = storedMessage(info, [textPart('aborted')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.roleLabel).toBe('Assistant'); + expect(content.modelLabel).toBe('claude-sonnet-4'); + expect(content.costLabel).toBeNull(); + expect(content.tokenRows).toBeNull(); + expect(content.copyableText).toBe('aborted'); + }); + + it('hides Copy when there is no copyable text', () => { + const message = storedMessage(userInfo(), []); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBeNull(); + expect(content.roleLabel).toBe('User'); + }); + + it('hides Sent when the created timestamp is missing', () => { + const info = userInfo({ + time: { created: 0 }, + }); + const message = storedMessage(info, [textPart('x')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.sentTimeLabel).toBeNull(); + }); +}); + +describe('MessageDetailsSheet copy button wiring (retryable unhappy)', () => { + beforeEach(() => { + performCopyMock.mockReset().mockResolvedValue(undefined); + showActionSheetWithOptions.mockReset(); + }); + + it('forwards copyable text to shared performCopy (no ActionSheet)', async () => { + // Contract: details Copy uses handleMessageDetailsCopy → shared performCopy. + // No ActionSheet (that path is for long-press message copy on iOS). + // Sheet onPress wires to this handler (see message-details-sheet.tsx). + const message = storedMessage(userInfo(), [textPart('copy me')]); + const content = getMessageDetailsContent(message, catalogOptions); + expect(content.copyableText).toBe('copy me'); + + const { handleMessageDetailsCopy } = await import('./message-details-copy'); + handleMessageDetailsCopy(content.copyableText); + + expect(performCopyMock).toHaveBeenCalledWith('copy me'); + expect(performCopyMock).toHaveBeenCalledTimes(1); + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + }); + + it('no-ops when copyable text is absent', async () => { + const { handleMessageDetailsCopy } = await import('./message-details-copy'); + handleMessageDetailsCopy(null); + handleMessageDetailsCopy(undefined); + handleMessageDetailsCopy(''); + expect(performCopyMock).not.toHaveBeenCalled(); + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/message-details-sheet.tsx b/apps/mobile/src/components/agents/message-details-sheet.tsx new file mode 100644 index 0000000000..28d6dc2d9f --- /dev/null +++ b/apps/mobile/src/components/agents/message-details-sheet.tsx @@ -0,0 +1,127 @@ +import { type StoredMessage } from 'cloud-agent-sdk'; +import { useMemo } from 'react'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SheetHeader } from '@/components/sheet-header'; +import { Text } from '@/components/ui/text'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { formatExactTokens } from './context-usage-display'; +import { handleMessageDetailsCopy } from './message-details-copy'; +import { getMessageDetailsContent } from './message-details-content'; + +type MessageDetailsSheetProps = { + visible: boolean; + message: StoredMessage | null; + modelOptions: SessionModelOption[]; + onClose: () => void; +}; + +export function MessageDetailsSheet({ + visible, + message, + modelOptions, + onClose, +}: Readonly) { + const insets = useSafeAreaInsets(); + const content = useMemo( + () => (message ? getMessageDetailsContent(message, modelOptions) : null), + [message, modelOptions] + ); + + const handleCopy = () => { + handleMessageDetailsCopy(content?.copyableText); + }; + + return ( + + + + + {content ? ( + + {content.copyableText ? ( + + + Copy message + + + ) : null} + + + + {content.roleLabel} + + + {content.sentTimeLabel ? ( + + + {content.sentTimeLabel} + + + ) : null} + + {content.modelLabel ? ( + + + {content.modelLabel} + + + ) : null} + + + {content.costLabel && content.tokenRows ? ( + + Cost & tokens + + + {content.costLabel} + + + + {content.tokenRows.map(row => ( + + ))} + + + ) : null} + + ) : null} + + + + + ); +} + +function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( + + {label} + {children} + + ); +} + +function TokenRow({ label, value }: Readonly<{ label: string; value: number }>) { + return ( + + {label} + + {formatExactTokens(value)} + + + ); +} diff --git a/apps/mobile/src/components/agents/message-model-label.test.ts b/apps/mobile/src/components/agents/message-model-label.test.ts index 6633d278a1..b8afd2e909 100644 --- a/apps/mobile/src/components/agents/message-model-label.test.ts +++ b/apps/mobile/src/components/agents/message-model-label.test.ts @@ -8,22 +8,15 @@ import { type UserMessage, } from 'cloud-agent-sdk'; -import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; - -import { computeMessageModelLabels, resolveMessageDisplayModel } from './message-model-label'; +import { resolveMessageDisplayModel } from './message-model-label'; /** - * F3 — per-message model label helpers. + * Routed-first model resolution for assistant messages (message details sheet). * * Contract: - * - `resolveMessageDisplayModel` prefers the LAST routed-model step-finish - * part over `info.providerID/modelID`, falls back to info, and returns - * null for user messages and when no model info is resolvable. - * - `computeMessageModelLabels` labels the FIRST assistant message and - * every subsequent assistant message whose resolved model DIFFERS from - * the previous assistant's. Same-model follow-ups stay unlabelled; a - * user message between two same-model assistants must not break the - * gating; the label string is `friendlyModelName(providerID, modelID, options)`. + * - prefers the LAST routed-model step-finish part over info.providerID/modelID + * - falls back to info when no routed part is present + * - returns null for user messages and when no model info is resolvable */ function assistantInfo(overrides: Partial = {}): AssistantMessage { @@ -83,29 +76,6 @@ function stepFinishWithRouted( return Object.assign(stepFinish(overrides), { model: routed }) as StepFinishPart; } -const catalogOption: SessionModelOption = { - id: 'anthropic/claude-sonnet-4', - name: 'Claude Sonnet 4', - displayId: 'claude-sonnet-4', - variants: [], - isPreferred: false, - showGatewayMetadata: false, - provider: { id: 'anthropic', name: 'Anthropic' }, - modelRef: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, -}; - -const kiloAutoOption: SessionModelOption = { - id: 'kilo-auto/efficient', - name: 'Kilo Auto (efficient)', - displayId: 'kilo-auto/efficient', - variants: [], - isPreferred: true, - showGatewayMetadata: true, - provider: { id: 'kilo', name: 'Kilo' }, -}; - -const options: SessionModelOption[] = [catalogOption, kiloAutoOption]; - describe('resolveMessageDisplayModel', () => { it('returns null for a user message', () => { const message = storedMessage(userInfo()); @@ -178,130 +148,3 @@ describe('resolveMessageDisplayModel', () => { expect(resolveMessageDisplayModel(message)).toBeNull(); }); }); - -describe('computeMessageModelLabels', () => { - it('labels the first assistant message even when later assistants share the model', () => { - const m1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'kilo', modelID: 'kilo-auto/efficient' }) - ); - const m2 = storedMessage( - assistantInfo({ id: 'a2', providerID: 'kilo', modelID: 'kilo-auto/efficient' }) - ); - const labels = computeMessageModelLabels([m1, m2], options); - expect(labels.get('a1')).toBe('Kilo Auto (efficient)'); - // m2 is the same model as m1 → no label. - expect(labels.has('a2')).toBe(false); - }); - - it('does NOT label a same-model follow-up assistant message', () => { - const m1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const m2 = storedMessage( - assistantInfo({ id: 'a2', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const labels = computeMessageModelLabels([m1, m2], options); - expect(labels.size).toBe(1); - expect(labels.get('a1')).toBe('Claude Sonnet 4'); - expect(labels.has('a2')).toBe(false); - }); - - it('labels a follow-up assistant message when its model DIFFERS from the previous', () => { - const m1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const m2 = storedMessage( - assistantInfo({ id: 'a2', providerID: 'kilo', modelID: 'kilo-auto/efficient' }) - ); - const labels = computeMessageModelLabels([m1, m2], options); - expect(labels.get('a1')).toBe('Claude Sonnet 4'); - expect(labels.get('a2')).toBe('Kilo Auto (efficient)'); - }); - - it('does NOT spuriously label when a user message sits between two same-model assistants', () => { - const user = storedMessage(userInfo({ id: 'u-mid' })); - const a1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const a2 = storedMessage( - assistantInfo({ id: 'a2', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const labels = computeMessageModelLabels([a1, user, a2], options); - expect(labels.size).toBe(1); - expect(labels.get('a1')).toBe('Claude Sonnet 4'); - expect(labels.has('a2')).toBe(false); - }); - - it('returns an empty map when there are no assistant messages', () => { - const user1 = storedMessage(userInfo({ id: 'u-1' })); - const user2 = storedMessage(userInfo({ id: 'u-2' })); - expect(computeMessageModelLabels([user1, user2], options)).toEqual(new Map()); - }); - - it('returns an empty map when no assistant message resolves a model', () => { - const a1 = storedMessage(assistantInfo({ id: 'a1', providerID: '', modelID: '' })); - const a2 = storedMessage(assistantInfo({ id: 'a2', providerID: '', modelID: '' })); - expect(computeMessageModelLabels([a1, a2], options)).toEqual(new Map()); - }); - - it('treats an unresolvable assistant as transparent: a following resolvable assistant compares against the LAST resolved model, not against null', () => { - const a1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const aUnresolvable = storedMessage(assistantInfo({ id: 'a2', providerID: '', modelID: '' })); - const a3 = storedMessage( - assistantInfo({ id: 'a3', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const labels = computeMessageModelLabels([a1, aUnresolvable, a3], options); - // a1 is labelled (first); a3 shares a1's model and must NOT be labelled. - expect(labels.size).toBe(1); - expect(labels.get('a1')).toBe('Claude Sonnet 4'); - expect(labels.has('a3')).toBe(false); - }); - - it('uses the friendlyModelName catalog hit when the resolved model is in the catalog', () => { - const a1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'anthropic', modelID: 'claude-sonnet-4' }) - ); - const labels = computeMessageModelLabels([a1], options); - expect(labels.get('a1')).toBe('Claude Sonnet 4'); - }); - - it('falls back to the cleaned raw modelID via friendlyModelName when the resolved model is NOT in the catalog', () => { - // Unresolvable id (no catalog hit) → cleaned raw id, with the trailing - // -YYYYMMDD date suffix stripped. Exercises the "Empty" branch of F3 - // (unresolvable id → never blank). - const a1 = storedMessage( - assistantInfo({ id: 'a1', providerID: 'kilo', modelID: 'claude-sonnet-4-20260101' }) - ); - const labels = computeMessageModelLabels([a1], options); - expect(labels.get('a1')).toBe('claude-sonnet-4'); - }); - - it('prefers the LAST routed step-finish model when computing the gate key', () => { - // m1 used kilo-auto/efficient info, but the LAST routed step-finish - // ran on anthropic/claude-sonnet-4. m2 is the SAME routed model, so - // it must NOT be labelled. - const m1 = storedMessage( - assistantInfo({ id: 'm1', providerID: 'kilo', modelID: 'kilo-auto/efficient' }), - [ - stepFinishWithRouted( - { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, - { id: 'sf-a' } - ), - stepFinishWithRouted( - { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, - { id: 'sf-b' } - ), - ] - ); - const m2 = storedMessage( - assistantInfo({ id: 'm2', providerID: 'anthropic', modelID: 'claude-sonnet-4' }), - [] - ); - const labels = computeMessageModelLabels([m1, m2], options); - expect(labels.get('m1')).toBe('Claude Sonnet 4'); - // m2's resolved model === m1's LAST routed model → no label. - expect(labels.has('m2')).toBe(false); - }); -}); diff --git a/apps/mobile/src/components/agents/message-model-label.ts b/apps/mobile/src/components/agents/message-model-label.ts index 6673ab9cbb..610ecd5d08 100644 --- a/apps/mobile/src/components/agents/message-model-label.ts +++ b/apps/mobile/src/components/agents/message-model-label.ts @@ -2,28 +2,13 @@ import { getStepFinishRoutedModel } from 'cloud-agent-sdk/part-utils'; import { type StoredMessage } from 'cloud-agent-sdk'; -import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; - -import { friendlyModelName } from './session-model-display'; - /** - * F3 — per-message model label helpers. + * Resolve the concrete (providerID, modelID) for one assistant message. * - * The assistant transcript can use a different model from message to - * message: the user switches mid-session, the CLI auto-routes a kilo-auto - * turn to a different upstream provider, etc. We want a subtle, dimmed - * "Claude Sonnet 4" / "Kilo Auto (efficient)" label to appear under the - * FIRST assistant message and whenever the resolved model CHANGES relative - * to the previous assistant message. Same-model follow-ups stay unlabelled - * (a quiet "the model hasn't changed" signal). - * - * Two layers of pure functions: - * - {@link resolveMessageDisplayModel}: pick the concrete (providerID, - * modelID) for ONE assistant message, preferring the routed model on - * the LAST step-finish part that carries one. - * - {@link computeMessageModelLabels}: walk the transcript in order and - * produce the map of messageId -> display label for exactly the - * assistant messages that should render a label. + * Prefers the routed model on the LAST step-finish part that carries one + * (kilo-auto and mid-session switches), then falls back to info-level + * provider/model. Used by message details (long-press sheet); the transcript + * no longer renders a per-message model label. */ type ResolvedModel = { providerID: string; modelID: string }; @@ -68,46 +53,3 @@ export function resolveMessageDisplayModel(message: StoredMessage): ResolvedMode } return null; } - -/** - * Walk the ordered transcript and return the subset of assistant message - * ids that should render a model label, mapped to their display string. - * - * Gating rule: the FIRST assistant message always shows its label (its - * "previous model" is `undefined`, so the key always differs); every - * following assistant message shows the label only when its resolved model - * differs from the previous assistant message's resolved model. User - * messages and other non-assistant messages are skipped and do NOT reset - * the running key. - */ -export function computeMessageModelLabels( - messages: readonly StoredMessage[], - options: SessionModelOption[] -): Map { - const labels = new Map(); - let previousKey: string | undefined = undefined; - - // Filter to assistant messages first so the inner loop stays straight-line - // (no `continue`, which the lint rules forbid) while preserving order. - const assistantMessages = messages.filter(message => message.info.role === 'assistant'); - - for (const message of assistantMessages) { - const resolved = resolveMessageDisplayModel(message); - // Unresolvable assistant message: never labelled, and do NOT update - // `previousKey` so the next assistant is still compared against the - // last successfully-resolved model. This matches the F3 "Empty" - // state (unresolvable id → no label rather than a wrong label). - if (resolved) { - const key = `${resolved.providerID}:${resolved.modelID}`; - if (key !== previousKey) { - labels.set( - message.info.id, - friendlyModelName(resolved.providerID, resolved.modelID, options) - ); - previousKey = key; - } - } - } - - return labels; -} diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx index 0b3fb7e529..7acc0adbcb 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -65,14 +65,14 @@ type NewSessionPromptProps = { }; /** - * New-session prompt surface: attachment strip, paperclip + multiline text - * input + voice toggle row, and the model/mode toolbar. Owns the prompt - * ref (for voice input to read), the height-measuring TextInput machinery, - * and the `useVoiceInput` hook. The route listens to `onChangeText` so the - * create handler can read the live prompt value after - * `settleVoiceInputBeforeSubmit` resolves; the attachment, repository, and - * create flows stay in the route so navigation and tRPC mutations stay - * colocated. + * New-session prompt surface: attachment strip, full-width multiline text + * input, bottom action row (paperclip leading, voice toggle trailing), and + * the model/mode toolbar. Owns the prompt ref (for voice input to read), the + * height-measuring TextInput machinery, and the `useVoiceInput` hook. The + * route listens to `onChangeText` so the create handler can read the live + * prompt value after `settleVoiceInputBeforeSubmit` resolves; the attachment, + * repository, and create flows stay in the route so navigation and tRPC + * mutations stay colocated. */ export function NewSessionPrompt({ attachments, @@ -165,29 +165,15 @@ export function NewSessionPrompt({ onRemove={onRemoveAttachment} onRetry={onRetryAttachment} /> - + {promptMeasure.measureElement} - - - - {voiceInput.available ? ( - + + + + + {voiceInput.available ? ( - - ) : null} + ) : null} + {voiceInput.available ? ( diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index c406eeff10..fb42571a50 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -21,7 +21,9 @@ import { getContextTone, } from './context-usage-display'; import { + getModelsSectionCount, getSessionCostBreakdown, + getVisibleSessionCostModels, type SessionCostBreakdown, type SessionCostBreakdownModel, } from './session-cost-breakdown'; @@ -70,7 +72,12 @@ export function SessionContextSheet({ () => getSessionCostBreakdown(messages, totalCost), [messages, totalCost] ); - const modelsSectionCount = breakdown.models.length + (breakdown.subagentCostUsd > 0 ? 1 : 0); + // Render-only filter: totals/subagent residual still use the full breakdown. + const visibleModels = useMemo( + () => getVisibleSessionCostModels(breakdown.models), + [breakdown.models] + ); + const modelsSectionCount = getModelsSectionCount(breakdown.models, breakdown.subagentCostUsd); return ( - {breakdown.models.map(model => ( + {visibleModels.map(model => ( = {}): AssistantMessage { + return { + id: 'msg-1', + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1 }, + parentID: 'msg-0', + modelID: 'claude-sonnet-4', + providerID: 'kilo', + mode: 'code', + agent: 'test', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + }; +} + +function stepFinish(overrides: Partial = {}): StepFinishPart { + return { + id: 'p-finish', + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'step-finish', + reason: 'stop', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + }; +} + +function stepFinishWithModel( + model: { providerID: string; modelID: string }, + overrides: Partial = {} +): StepFinishPart { + return Object.assign(stepFinish(overrides), { model }) as StepFinishPart; +} + +function storedMessage(info: AssistantMessage, parts: Part[] = []): StoredMessage { + return { info, parts }; +} + +function modelRow( + overrides: Partial & + Pick +): SessionCostBreakdownModel { + return { + steps: 1, + costUsd: 0.01, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + total: 2, + }, + ...overrides, + }; +} + +const oneOneTokens = { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }; + +describe('isHiddenAutoModelRow', () => { + it('hides kilo provider rows whose modelID starts with kilo-auto/', () => { + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient' })).toBe(true); + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'kilo-auto/frontier' })).toBe(true); + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'kilo-auto/balanced' })).toBe(true); + }); + + it('keeps routed concrete models on the kilo provider', () => { + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' })).toBe( + false + ); + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'claude-sonnet-4' })).toBe(false); + expect(isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'openai/gpt-4o' })).toBe(false); + }); + + it('does not hide auto-prefixed ids on non-kilo providers', () => { + expect(isHiddenAutoModelRow({ providerID: 'openrouter', modelID: 'kilo-auto/efficient' })).toBe( + false + ); + expect(isHiddenAutoModelRow({ providerID: 'anthropic', modelID: 'kilo-auto/efficient' })).toBe( + false + ); + }); + + it('does not hide non-auto kilo models that merely contain kilo-auto elsewhere', () => { + expect( + isHiddenAutoModelRow({ providerID: 'kilo', modelID: 'prefix-kilo-auto/efficient' }) + ).toBe(false); + }); +}); + +describe('getVisibleSessionCostModels / getModelsSectionCount', () => { + it('filters auto rows and keeps routed + non-kilo rows', () => { + const models = [ + modelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient', steps: 4 }), + modelRow({ providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4', steps: 2 }), + modelRow({ providerID: 'openai', modelID: 'gpt-4o', steps: 1 }), + ]; + const visible = getVisibleSessionCostModels(models); + expect(visible).toHaveLength(2); + expect(visible.map(m => m.modelID)).toEqual(['anthropic/claude-sonnet-4', 'gpt-4o']); + }); + + it('derives Models (N) from filtered rows + residual, never unfiltered length', () => { + const autoOnly = [modelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient' })]; + // Auto-only, no residual → section hidden (count 0) + expect(getModelsSectionCount(autoOnly, 0)).toBe(0); + // Auto-only + residual → count is residual only (1), not 2 + expect(getModelsSectionCount(autoOnly, 0.02)).toBe(1); + + const mixed = [ + modelRow({ providerID: 'kilo', modelID: 'kilo-auto/efficient' }), + modelRow({ providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }), + ]; + // Filtered list (1) + residual → 2, not unfiltered 2 + residual = 3 + expect(getModelsSectionCount(mixed, 0.02)).toBe(2); + // Filtered list only → 1 + expect(getModelsSectionCount(mixed, 0)).toBe(1); + }); + + it('returns zero when there are no models and no residual', () => { + expect(getModelsSectionCount([], 0)).toBe(0); + }); + + it('counts residual alone when models list is empty', () => { + expect(getModelsSectionCount([], 0.05)).toBe(1); + }); +}); + +describe('getSessionCostBreakdown (filter is render-only)', () => { + it('keeps auto-model rows in breakdown totals and residual', () => { + const messages: StoredMessage[] = [ + storedMessage( + assistantInfo({ + id: 'm1', + providerID: 'kilo', + modelID: 'kilo-auto/efficient', + cost: 0.04, + }), + [ + stepFinishWithModel( + { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + { id: 'sf-1', cost: 0.04, tokens: oneOneTokens } + ), + ] + ), + ]; + const result = getSessionCostBreakdown(messages, 0.06); + expect(result.models).toHaveLength(1); + expect(result.models[0]?.modelID).toBe('kilo-auto/efficient'); + expect(result.attributedCostUsd).toBeCloseTo(0.04, 6); + expect(result.subagentCostUsd).toBeCloseTo(0.02, 6); + expect(result.totals.input).toBe(1); + expect(result.totals.output).toBe(1); + // Display layer would hide the auto row and show residual only + expect(getModelsSectionCount(result.models, result.subagentCostUsd)).toBe(1); + expect(getVisibleSessionCostModels(result.models)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/components/agents/session-cost-breakdown.ts b/apps/mobile/src/components/agents/session-cost-breakdown.ts index f6bfd4df89..7f9707ed72 100644 --- a/apps/mobile/src/components/agents/session-cost-breakdown.ts +++ b/apps/mobile/src/components/agents/session-cost-breakdown.ts @@ -168,6 +168,38 @@ export function getSessionCostBreakdown( return { totals, models, attributedCostUsd, subagentCostUsd }; } +/** + * Models-section display filter (R8): hide the session's selected auto model + * so the list only shows concrete routed models. Render-only — never applied + * when computing totals or the subagent residual. + * + * Hidden iff provider is `kilo` and modelID starts with `kilo-auto/`. + * Routed rows (`providerID: 'kilo'`, concrete `modelID` e.g. `anthropic/...`) + * always pass. Info-fallback steps that resolve only to an auto id are hidden + * by the same rule. + */ +export function isHiddenAutoModelRow(model: { providerID: string; modelID: string }): boolean { + return model.providerID === 'kilo' && model.modelID.startsWith('kilo-auto/'); +} + +/** Models rows that should render in the context-sheet Models section. */ +export function getVisibleSessionCostModels( + models: SessionCostBreakdownModel[] +): SessionCostBreakdownModel[] { + return models.filter(model => !isHiddenAutoModelRow(model)); +} + +/** + * Section title count and visibility source: filtered model rows + optional + * Subagents residual. Never derived from the unfiltered list. + */ +export function getModelsSectionCount( + models: SessionCostBreakdownModel[], + subagentCostUsd: number +): number { + return getVisibleSessionCostModels(models).length + (subagentCostUsd > 0 ? 1 : 0); +} + function collectStepFinishParts(parts: Part[]): StepFinishPart[] { const out: StepFinishPart[] = []; for (const part of parts) { diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index f8c9d9c0b4..68f7c77ce8 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Session orchestration and its render paths are kept together. */ -import { type CloudStatus, type KiloSessionId } from 'cloud-agent-sdk'; +import { type CloudStatus, type KiloSessionId, type StoredMessage } from 'cloud-agent-sdk'; import { type Href, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { MessageSquare } from 'lucide-react-native'; @@ -15,7 +15,7 @@ import { createAndNavigateAgentSession } from '@/components/agents/create-and-na import { exitRemoteSessionWithFeedback } from '@/components/agents/exit-remote-session-with-feedback'; import { ConnectivityBanner } from '@/components/agents/connectivity-banner'; import { MessageBubble } from '@/components/agents/message-bubble'; -import { computeMessageModelLabels } from '@/components/agents/message-model-label'; +import { MessageDetailsSheet } from '@/components/agents/message-details-sheet'; import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector'; import { PermissionCard } from '@/components/agents/permission-card'; import { QuestionCard } from '@/components/agents/question-card'; @@ -40,6 +40,7 @@ import { PreparationGroup } from '@/components/agents/preparation-group'; import { shouldShowAgentWorkingIndicator, shouldShowFooterWorkingIndicator, + shouldShowSessionFooterRow, } from '@/components/agents/session-working-state'; import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; @@ -144,6 +145,7 @@ export function SessionDetailContent({ const olderMessagesOmittedItemCount = useAtomValue(manager.atoms.olderMessagesOmittedItemCount); const [openContextSheetIdentity, setOpenContextSheetIdentity] = useState(null); + const [detailsMessage, setDetailsMessage] = useState(null); const { isConnected } = useAppLifecycle(); const { bottom } = useSafeAreaInsets(); @@ -333,16 +335,6 @@ export function SessionDetailContent({ return null; }, [messages]); - // Per-message model label gating: the first assistant message is always - // labelled, and every subsequent assistant message is labelled only when - // its resolved model differs from the previous assistant's. Walked over - // the ordered transcript here so each `` only needs to - // consult a Map lookup, not re-derive the answer from the whole list. - const messageModelLabels = useMemo( - () => computeMessageModelLabels(messages, modelOptions), - [messages, modelOptions] - ); - const handleOpenChildSession = useCallback( (childSessionId: KiloSessionId, childTitle: string) => { setChildSession({ sessionId: childSessionId, title: childTitle }); @@ -376,11 +368,7 @@ export function SessionDetailContent({ defaultReasoningExpanded={reasoningDefaultExpanded} onOpenChildSession={handleOpenChildSession} deliveryState={deliveryState} - modelLabel={ - item.message.info.role === 'assistant' - ? messageModelLabels.get(item.message.info.id) - : undefined - } + onLongPressDetails={setDetailsMessage} /> ); }, @@ -391,7 +379,6 @@ export function SessionDetailContent({ reasoningDefaultExpanded, handleOpenChildSession, pendingMessages, - messageModelLabels, ] ); @@ -452,10 +439,25 @@ export function SessionDetailContent({ isStreaming, pendingMessageCount: pendingMessages.size, }); + const hasFooterStatusIndicator = + statusIndicator !== null || (cloudStatus !== null && cloudStatus.type !== 'ready'); const shouldShowFooterWorking = shouldShowFooterWorkingIndicator({ isAgentWorking: shouldShowWorkingIndicator, - hasStatusIndicator: - statusIndicator !== null || (cloudStatus !== null && cloudStatus.type !== 'ready'), + hasStatusIndicator: hasFooterStatusIndicator, + }); + // Only a live PreparationGroup duplicates footer progress. Completed groups + // stay in the transcript after cold starts and must not suppress a later + // recycle re-prepare footer (Setting up environment…). + const hasInProgressTranscriptPreparation = useMemo( + () => transcript.some(item => item.type === 'preparation' && item.attempt.status === 'running'), + [transcript] + ); + const showSessionFooterRow = shouldShowSessionFooterRow({ + cloudStatusType: cloudStatus?.type, + hasInProgressTranscriptPreparation, + shouldShowFooterWorking, + hasStatusIndicator: statusIndicator !== null, + messageCount: messages.length, }); const emptyStateText = statusIndicator ? null : 'No messages yet'; @@ -653,6 +655,15 @@ export function SessionDetailContent({ /> ) : null} + { + setDetailsMessage(null); + }} + /> + {childSession ? ( 0 && (shouldShowFooterWorking || statusIndicator) ? ( + does not double-render. While preparing, suppressed when the + transcript already shows PreparationGroup (no duplicate). */} + {showSessionFooterRow ? ( { @@ -62,3 +63,117 @@ describe('shouldShowFooterWorkingIndicator', () => { ).toBe(false); }); }); + +describe('shouldShowSessionFooterRow', () => { + const base = { + shouldShowFooterWorking: false, + hasStatusIndicator: true, + messageCount: 1, + }; + + it('hides while preparing when the transcript shows an in-progress preparation', () => { + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'preparing', + hasInProgressTranscriptPreparation: true, + }) + ).toBe(false); + }); + + it('shows while preparing when the transcript has no live preparation surface', () => { + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'preparing', + hasInProgressTranscriptPreparation: false, + }) + ).toBe(true); + }); + + it('shows while preparing when only a completed (stale) preparation is in the transcript', () => { + // Recycle re-prepare: prior non-no-op completed group remains rendered, but + // the new running attempt is not merged yet — footer must stay visible. + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'preparing', + hasInProgressTranscriptPreparation: false, + }) + ).toBe(true); + }); + + it('hides while preparing when a running non-no-op preparation is in the transcript', () => { + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'preparing', + hasInProgressTranscriptPreparation: true, + }) + ).toBe(false); + }); + + it('keeps non-preparing behavior: shows when status or footer working is set', () => { + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'ready', + hasInProgressTranscriptPreparation: true, + hasStatusIndicator: true, + shouldShowFooterWorking: false, + }) + ).toBe(true); + + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: null, + hasInProgressTranscriptPreparation: false, + hasStatusIndicator: false, + shouldShowFooterWorking: true, + }) + ).toBe(true); + + expect( + shouldShowSessionFooterRow({ + ...base, + cloudStatusType: 'ready', + hasInProgressTranscriptPreparation: false, + hasStatusIndicator: false, + shouldShowFooterWorking: false, + }) + ).toBe(false); + }); + + it('keeps footer-working rules and hides the row when there are no messages', () => { + expect( + shouldShowSessionFooterRow({ + cloudStatusType: null, + hasInProgressTranscriptPreparation: false, + shouldShowFooterWorking: true, + hasStatusIndicator: false, + messageCount: 3, + }) + ).toBe(true); + + expect( + shouldShowSessionFooterRow({ + cloudStatusType: 'preparing', + hasInProgressTranscriptPreparation: false, + shouldShowFooterWorking: false, + hasStatusIndicator: true, + messageCount: 0, + }) + ).toBe(false); + + expect( + shouldShowSessionFooterRow({ + cloudStatusType: null, + hasInProgressTranscriptPreparation: false, + shouldShowFooterWorking: true, + hasStatusIndicator: false, + messageCount: 0, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/session-working-state.ts b/apps/mobile/src/components/agents/session-working-state.ts index 4b7595f2d6..6a4b56f5d0 100644 --- a/apps/mobile/src/components/agents/session-working-state.ts +++ b/apps/mobile/src/components/agents/session-working-state.ts @@ -8,6 +8,15 @@ type FooterWorkingIndicatorInput = { hasStatusIndicator: boolean; }; +type SessionFooterRowInput = { + cloudStatusType: string | null | undefined; + /** True only when the transcript shows a live (running) PreparationGroup. */ + hasInProgressTranscriptPreparation: boolean; + shouldShowFooterWorking: boolean; + hasStatusIndicator: boolean; + messageCount: number; +}; + export function shouldShowAgentWorkingIndicator({ isStreaming, pendingMessageCount, @@ -21,3 +30,27 @@ export function shouldShowFooterWorkingIndicator({ }: FooterWorkingIndicatorInput): boolean { return isAgentWorking && !hasStatusIndicator; } + +/** + * Fixed footer row above the composer (working spinner and/or cloud status). + * While cloud-agent preparation is in flight AND the transcript already shows + * a live PreparationGroup, hide the footer so progress is not duplicated. + * Stale completed/failed groups must not suppress the footer — otherwise a + * recycle re-prepare can leave a blank progress window until the new running + * attempt merges. Zero-message empty state is handled elsewhere. + */ +export function shouldShowSessionFooterRow({ + cloudStatusType, + hasInProgressTranscriptPreparation, + shouldShowFooterWorking, + hasStatusIndicator, + messageCount, +}: SessionFooterRowInput): boolean { + if (messageCount === 0) { + return false; + } + if (cloudStatusType === 'preparing' && hasInProgressTranscriptPreparation) { + return false; + } + return shouldShowFooterWorking || hasStatusIndicator; +} diff --git a/apps/mobile/src/components/agents/use-message-copy.test.ts b/apps/mobile/src/components/agents/use-message-copy.test.ts new file mode 100644 index 0000000000..4d8025b4bc --- /dev/null +++ b/apps/mobile/src/components/agents/use-message-copy.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const setStringAsync = vi.fn(); +const notificationAsync = vi.fn(); +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +const showActionSheetWithOptions = vi.fn(); + +vi.mock('expo-clipboard', () => ({ + setStringAsync: (...args: unknown[]) => setStringAsync(...args), +})); +vi.mock('expo-haptics', () => ({ + notificationAsync: (...args: unknown[]) => notificationAsync(...args), + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); +vi.mock('react-native', () => ({ + Platform: { OS: 'android' }, + ActionSheetIOS: { + showActionSheetWithOptions: (...args: unknown[]) => showActionSheetWithOptions(...args), + }, +})); + +describe('performCopy', () => { + beforeEach(() => { + setStringAsync.mockReset(); + notificationAsync.mockReset(); + toastSuccess.mockReset(); + toastError.mockReset(); + showActionSheetWithOptions.mockReset(); + }); + + it('writes to the clipboard, fires success haptic, and toasts success', async () => { + setStringAsync.mockResolvedValue(undefined); + const { performCopy } = await import('./use-message-copy'); + await performCopy('hello'); + expect(setStringAsync).toHaveBeenCalledWith('hello'); + expect(notificationAsync).toHaveBeenCalledWith('success'); + expect(toastSuccess).toHaveBeenCalledWith('Copied to clipboard'); + expect(toastError).not.toHaveBeenCalled(); + }); + + it('toasts an error on clipboard failure and does not throw (sheet stays open)', async () => { + setStringAsync.mockRejectedValue(new Error('denied')); + const { performCopy } = await import('./use-message-copy'); + await expect(performCopy('hello')).resolves.toBeUndefined(); + expect(toastError).toHaveBeenCalledWith('Could not copy to clipboard'); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(notificationAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/use-message-copy.ts b/apps/mobile/src/components/agents/use-message-copy.ts index f258ef59aa..6b5a7d0d33 100644 --- a/apps/mobile/src/components/agents/use-message-copy.ts +++ b/apps/mobile/src/components/agents/use-message-copy.ts @@ -32,7 +32,12 @@ export function useMessageCopy() { return { copyMessage }; } -async function performCopy(text: string) { +/** + * Immediate clipboard write used by the message-details sheet and by the + * a11y/ActionSheet copy path after the user confirms. Success haptic + toast; + * failure → error toast (caller keeps its UI open). + */ +export async function performCopy(text: string): Promise { try { await Clipboard.setStringAsync(text); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx index fc91abb5ae..d2e1ea2670 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx @@ -33,19 +33,19 @@ export function NavigatorFileRow({ const colors = useThemeColors(); const { dir, basename } = splitPath(file.path); return ( - + - - + + {dir.length > 0 ? ( ) : null} - + {basename} @@ -72,7 +72,9 @@ export function NavigatorFileRow({ - + + + ); } diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx index bea76a923c..fa3190f464 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx @@ -1,10 +1,15 @@ // Form sub-components for the S8 merge sheet. Extracted out of // `pr-merge-sheet.tsx` to keep that file under the repo's 300-line limit. -import { type RefObject } from 'react'; -import { Switch, TextInput, View } from 'react-native'; +import { type ReactNode, type RefObject } from 'react'; +import { Pressable, Switch, TextInput, View } from 'react-native'; +import * as Haptics from 'expo-haptics'; -import { PillGroup } from '@/components/security-agent/settings-pill-group'; +import { + PrFormSheetFooter, + useFormSheetKeyboardVisible, +} from '@/components/pr-review/pr-form-sheet-chrome'; +import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; @@ -13,8 +18,19 @@ import { PR_MERGE_DESCRIPTIONS, } from '@/lib/pr-review/merge/merge-blocked-reasons'; import { type MergeMethodOption } from '@/components/pr-review/merge/pr-merge-icons'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; + +function shortMethodChipLabel(value: AllowedMergeMethod): string { + if (value === 'merge') { + return 'Merge'; + } + if (value === 'squash') { + return 'Squash'; + } + return 'Rebase'; +} -export function MethodPicker({ +function MethodPicker({ methodOptions, method, isDisabled, @@ -26,27 +42,50 @@ export function MethodPicker({ onChange: (next: AllowedMergeMethod) => void; }>) { return ( - - + + Method - ({ value: o.value, label: o.label }))} - value={method} - disabled={isDisabled} - onChange={value => { - onChange(value); - }} - /> - - {PR_MERGE_DESCRIPTIONS[method]} - + + {methodOptions.map(option => { + const active = method === option.value; + // Long labels stay readable via accessibilityLabel; chip shows short text. + const shortLabel = shortMethodChipLabel(option.value); + return ( + { + void Haptics.selectionAsync(); + onChange(option.value); + }} + accessibilityRole="radio" + accessibilityState={{ selected: active, disabled: isDisabled }} + accessibilityLabel={option.label} + accessibilityHint={PR_MERGE_DESCRIPTIONS[option.value]} + className={cn( + 'min-h-8 items-center justify-center rounded-full border px-2.5 py-1 active:opacity-70', + active ? 'border-primary bg-primary' : 'border-border bg-secondary', + isDisabled && 'opacity-50' + )} + > + + {shortLabel} + + + ); + })} + ); } -export function CommitTitleField({ +function CommitTitleField({ titleRef, inputRef, placeholder, @@ -59,8 +98,8 @@ export function CommitTitleField({ }>) { const colors = useThemeColors(); return ( - - Commit title + + Commit title ; inputRef: RefObject; isDisabled: boolean; + compact?: boolean; }>) { const colors = useThemeColors(); + const keyboardVisible = useFormSheetKeyboardVisible(); + const tight = compact || keyboardVisible; return ( - - Commit message + + Commit message ); - } else if (slotState === 'hint') { - helperContent = ( - - Paste a link like {URL_PLACEHOLDER} - - ); } let recentsBody: ReactNode = null; diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx index 64f59878c1..847725bd95 100644 --- a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { type ReactNode } from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { QueryError } from '@/components/query-error'; -import { ScreenHeader } from '@/components/screen-header'; import { PrMergeSheet } from '@/components/pr-review/merge/pr-merge-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; @@ -38,6 +38,11 @@ export function PrReviewMergeScreen() { const method: PrMergeMethod = MERGE_METHODS.has(params.method as PrMergeMethod) ? (params.method as PrMergeMethod) : 'merge'; + const sheetTitle = mode === 'enable-auto-merge' ? 'Enable auto-merge' : 'Merge pull request'; + const eyebrow = `${owner}/${repo}#${rawNumber}`; + const dismiss = () => { + router.back(); + }; const trpc = useTRPC(); const pr = useQuery( @@ -47,28 +52,8 @@ export function PrReviewMergeScreen() { ) ); - let content: ReactNode = null; - if (pr.isLoading) { - content = ( - - - - ); - } else if (pr.isError || !pr.data) { - content = ( - - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - - ); - } else { - content = ( + if (pr.data) { + return ( { await pr.refetch(); }} - onDismiss={() => { - router.back(); - }} + onDismiss={dismiss} /> ); } - return ( - - { - router.back(); - }} - /> - {content} + const body: ReactNode = pr.isLoading ? ( + + + ) : ( + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + ); + + return ( + <> + + + {body} + + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-pending-comment-row.tsx b/apps/mobile/src/components/pr-review/pr-review-pending-comment-row.tsx index 6510d631c5..d70cfc7efb 100644 --- a/apps/mobile/src/components/pr-review/pr-review-pending-comment-row.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-pending-comment-row.tsx @@ -6,6 +6,7 @@ import { type RefObject } from 'react'; import { Trash2 } from 'lucide-react-native'; import { Pressable, TextInput, View } from 'react-native'; +import { useFormSheetKeyboardVisible } from '@/components/pr-review/pr-form-sheet-chrome'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider'; @@ -28,18 +29,18 @@ export function PrReviewPendingCommentRow({ const location = pendingCommentLocationLabel(item); return ( - + {location} - + {item.body.trim().length > 0 ? item.body : '(empty)'} @@ -49,9 +50,9 @@ export function PrReviewPendingCommentRow({ hitSlop={8} accessibilityRole="button" accessibilityLabel={`Delete pending comment on ${location}`} - className="h-9 w-9 items-center justify-center rounded-md active:opacity-60" + className="h-8 w-8 items-center justify-center rounded-md active:opacity-60" > - + ); @@ -104,6 +105,7 @@ export function ReviewSummaryField({ onChange: () => void; }) { const colors = useThemeColors(); + const keyboardVisible = useFormSheetKeyboardVisible(); return ( ); diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx index 5ca9e00cb7..0e04271188 100644 --- a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx @@ -1,11 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { type ReactNode } from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { PrReviewSubmit } from '@/components/pr-review/pr-review-submit'; import { QueryError } from '@/components/query-error'; -import { ScreenHeader } from '@/components/screen-header'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { parseParam } from '@/lib/route-params'; import { useTRPC } from '@/lib/trpc'; @@ -24,6 +24,11 @@ export function PrReviewReviewSubmitScreen() { const repo = parseParam(params.repo) ?? ''; const rawNumber = parseParam(params.number) ?? ''; const number = Number.parseInt(rawNumber, 10); + const title = 'Submit review'; + const eyebrow = `${owner}/${repo}#${rawNumber}`; + const dismiss = () => { + router.back(); + }; const trpc = useTRPC(); const pr = useQuery( @@ -33,51 +38,41 @@ export function PrReviewReviewSubmitScreen() { ) ); - let content: ReactNode = null; - if (pr.isLoading) { - content = ( - - - - ); - } else if (pr.isError || !pr.data) { - content = ( - - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - - ); - } else { - content = ( + if (pr.data) { + return ( { - router.back(); - }} + title={title} + eyebrow={eyebrow} + onDismiss={dismiss} /> ); } - return ( - - { - router.back(); - }} - /> - {content} + const body: ReactNode = pr.isLoading ? ( + + + ) : ( + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + ); + + return ( + <> + + + {body} + + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx index 49c50f7b66..321ac349c1 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -9,10 +9,14 @@ import * as Haptics from 'expo-haptics'; import { type Href, useRouter } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; -import { Alert, ScrollView, type TextInput, View } from 'react-native'; +import { Alert, Keyboard, Pressable, ScrollView, type TextInput, View } from 'react-native'; +import { + PrFormSheetFooter, + PrFormSheetHeader, + useFormSheetKeyboardVisible, +} from '@/components/pr-review/pr-form-sheet-chrome'; import { Button } from '@/components/ui/button'; -import { PillGroup } from '@/components/security-agent/settings-pill-group'; import { Text } from '@/components/ui/text'; import { PendingQueueHint, @@ -28,6 +32,7 @@ import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-revie import { mutationErrorDisplay } from '@/lib/pr-review/mutation-error-display'; import { type PendingReviewItem, usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { cn } from '@/lib/utils'; const COMMENT_COMPOSER_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/comment-composer' as const; @@ -36,6 +41,8 @@ type PrReviewSubmitProps = Readonly<{ repo: string; number: number; headSha: string; + title: string; + eyebrow: string; onDismiss: () => void; }>; @@ -46,7 +53,7 @@ const EVENT_OPTIONS: readonly { value: ReviewEvent; label: string }[] = [ ]; export function PrReviewSubmit(props: PrReviewSubmitProps) { - const { owner, repo, number, headSha, onDismiss } = props; + const { owner, repo, number, headSha, title, eyebrow, onDismiss } = props; const router = useRouter(); const pending = usePendingReview(); const submitReview = useSubmitReviewMutation({ owner, repo, number }); @@ -59,6 +66,7 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { const bodyRef = useRef(''); const bodyInputRef = useRef(null); + const scrollRef = useRef(null); const isSubmitting = submitReview.isPending; const queuedCount = pending.items.length; @@ -73,6 +81,17 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { } }, [submitReview.error]); + useEffect(() => { + const sub = Keyboard.addListener('keyboardDidShow', () => { + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ y: 0, animated: false }); + }); + }); + return () => { + sub.remove(); + }; + }, []); + function clearRecoverableError() { // bad-request / retryable clear on edit; forbidden stays for the session. if (inlineErrorKind === 'bad-request' || inlineErrorKind === 'retryable') { @@ -141,90 +160,151 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { inlineErrorKind === 'forbidden' || inlineErrorKind === 'reconnect'; + const keyboardVisible = useFormSheetKeyboardVisible(); + // Keyboard-open viewport is tight; keep count, hide per-item rows so + // Submit + Cancel stay above the keyboard at scroll offset 0. + const showPendingRows = !keyboardVisible; + + // PickerSheet invariant: [header, ScrollView]; footer is trailing content. return ( - + <> + - { - setEvent(next); - clearRecoverableError(); - }} - /> - - Summary (optional) - + { + setEvent(next); + clearRecoverableError(); + }} /> - - - - - {queuedCount} pending {queuedCount === 1 ? 'comment' : 'comments'} - - - {pending.items.map(item => ( - { - openEditComposer(item); - }} - onDelete={() => { - confirmDelete(item); - }} + + Summary (optional) + - ))} + + + + + {queuedCount} pending {queuedCount === 1 ? 'comment' : 'comments'} + + {/* Hint only when empty/stale — skips the long happy-path line that + pushed footer CTAs below half-detent. */} + {!keyboardVisible && (queuedCount === 0 || hasStaleItems) ? ( + + ) : null} + {showPendingRows + ? pending.items.map(item => ( + { + openEditComposer(item); + }} + onDelete={() => { + confirmDelete(item); + }} + /> + )) + : null} + + + {inlineError && inlineErrorKind !== 'reconnect' ? ( + + {inlineError} + + ) : null} + {inlineErrorKind === 'reconnect' ? : null} - {inlineError && inlineErrorKind !== 'reconnect' ? ( - + + + + + ); +} - - - +/** Horizontal event chips — vertical PillGroup is too tall for half-detent. */ +function ReviewEventChips(props: { + value: ReviewEvent; + disabled: boolean; + onChange: (next: ReviewEvent) => void; +}) { + return ( + + + Review event + + + {EVENT_OPTIONS.map(option => { + const active = props.value === option.value; + return ( + { + void Haptics.selectionAsync(); + props.onChange(option.value); + }} + accessibilityRole="radio" + accessibilityState={{ selected: active, disabled: props.disabled }} + accessibilityLabel={option.label} + className={cn( + 'min-h-9 items-center justify-center rounded-full border px-3 py-1.5 active:opacity-70', + active ? 'border-primary bg-primary' : 'border-border bg-secondary', + props.disabled && 'opacity-50' + )} + > + + {option.label} + + + ); + })} ); diff --git a/apps/mobile/src/lib/pr-review/pr-link-helper-slot.test.ts b/apps/mobile/src/lib/pr-review/pr-link-helper-slot.test.ts index 87df4703bb..e1c9310c10 100644 --- a/apps/mobile/src/lib/pr-review/pr-link-helper-slot.test.ts +++ b/apps/mobile/src/lib/pr-review/pr-link-helper-slot.test.ts @@ -7,34 +7,22 @@ import { } from './pr-link-helper-slot'; describe('selectPrLinkHelperSlotState', () => { - it('returns hint when the field is empty and no message is active', () => { - expect(selectPrLinkHelperSlotState({ hasInput: false, message: null })).toBe('hint'); + it('returns none when no message is active', () => { + expect(selectPrLinkHelperSlotState({ message: null })).toBe('none'); }); - it('returns none when the field has text and no message is active', () => { - expect(selectPrLinkHelperSlotState({ hasInput: true, message: null })).toBe('none'); + it('returns invalid when the invalid message is active', () => { + expect(selectPrLinkHelperSlotState({ message: 'invalid' })).toBe('invalid'); }); - it('returns invalid when the invalid message is active, regardless of input', () => { - expect(selectPrLinkHelperSlotState({ hasInput: false, message: 'invalid' })).toBe('invalid'); - expect(selectPrLinkHelperSlotState({ hasInput: true, message: 'invalid' })).toBe('invalid'); + it('returns clipboard-empty when that message is active', () => { + expect(selectPrLinkHelperSlotState({ message: 'clipboard-empty' })).toBe('clipboard-empty'); }); - it('returns clipboard-empty when that message is active, regardless of input', () => { - expect(selectPrLinkHelperSlotState({ hasInput: false, message: 'clipboard-empty' })).toBe( - 'clipboard-empty' - ); - expect(selectPrLinkHelperSlotState({ hasInput: true, message: 'clipboard-empty' })).toBe( - 'clipboard-empty' - ); - }); - - it('gives messages priority over hint and none (last-set wins at the call site)', () => { + it('gives messages priority over none (last-set wins at the call site)', () => { // Single message field — whichever the UI last set is what we select. - expect(selectPrLinkHelperSlotState({ hasInput: false, message: 'invalid' })).toBe('invalid'); - expect(selectPrLinkHelperSlotState({ hasInput: false, message: 'clipboard-empty' })).toBe( - 'clipboard-empty' - ); + expect(selectPrLinkHelperSlotState({ message: 'invalid' })).toBe('invalid'); + expect(selectPrLinkHelperSlotState({ message: 'clipboard-empty' })).toBe('clipboard-empty'); }); it('exports the pinned helper copy strings', () => { diff --git a/apps/mobile/src/lib/pr-review/pr-link-helper-slot.ts b/apps/mobile/src/lib/pr-review/pr-link-helper-slot.ts index df2f2620bb..3ea42b63b0 100644 --- a/apps/mobile/src/lib/pr-review/pr-link-helper-slot.ts +++ b/apps/mobile/src/lib/pr-review/pr-link-helper-slot.ts @@ -1,10 +1,8 @@ export type PrLinkHelperMessage = 'invalid' | 'clipboard-empty'; -type PrLinkHelperSlotState = 'invalid' | 'clipboard-empty' | 'hint' | 'none'; +type PrLinkHelperSlotState = 'invalid' | 'clipboard-empty' | 'none'; type PrLinkHelperSlotInput = { - /** Whether the PR-link field currently has any text. */ - readonly hasInput: boolean; /** * Active transient message. `invalid` and `clipboard-empty` are mutually * exclusive at the call site (last-set wins); `null` means no message. @@ -15,10 +13,10 @@ type PrLinkHelperSlotInput = { /** * Select the reserved-height helper-slot content for the PR-link entry field. * - * Priority: active message (invalid / clipboard-empty) wins over hint/none. - * Hint only when the field is empty and no message is active; none when the - * field has text and no message is active. The slot always keeps fixed height - * in the UI regardless of which state is selected. + * Priority: active message (invalid / clipboard-empty) wins over none. + * No active message selects none — the input placeholder already shows the + * example URL. The slot always keeps fixed height in the UI regardless of + * which state is selected. */ export function selectPrLinkHelperSlotState(input: PrLinkHelperSlotInput): PrLinkHelperSlotState { if (input.message === 'invalid') { @@ -27,9 +25,6 @@ export function selectPrLinkHelperSlotState(input: PrLinkHelperSlotInput): PrLin if (input.message === 'clipboard-empty') { return 'clipboard-empty'; } - if (!input.hasInput) { - return 'hint'; - } return 'none'; } diff --git a/services/cloud-agent-next/Dockerfile b/services/cloud-agent-next/Dockerfile index 2c5cdab1fa..01e7cc8197 100644 --- a/services/cloud-agent-next/Dockerfile +++ b/services/cloud-agent-next/Dockerfile @@ -3,7 +3,7 @@ FROM docker.io/cloudflare/sandbox:0.12.1 # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.54" +ARG KILOCODE_CLI_VERSION="7.3.63" # Install latest stable git + git-lfs from the git-core PPA, GitHub CLI, and supporting tools. # The default Ubuntu git (2.34.1 on 22.04) is outdated; the git-core PPA ships the latest diff --git a/services/cloud-agent-next/Dockerfile.dev b/services/cloud-agent-next/Dockerfile.dev index b97658222f..405fd4b76d 100644 --- a/services/cloud-agent-next/Dockerfile.dev +++ b/services/cloud-agent-next/Dockerfile.dev @@ -3,7 +3,7 @@ FROM docker.io/cloudflare/sandbox:0.12.1 # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.54" +ARG KILOCODE_CLI_VERSION="7.3.63" # Build the kilo binary: # cd ~/projects/kilocode-backend/cloud-agent diff --git a/services/cloud-agent-next/Dockerfile.dind b/services/cloud-agent-next/Dockerfile.dind index ad0823d862..fb65597e11 100644 --- a/services/cloud-agent-next/Dockerfile.dind +++ b/services/cloud-agent-next/Dockerfile.dind @@ -9,7 +9,7 @@ USER root # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.54" +ARG KILOCODE_CLI_VERSION="7.3.63" # Cloudflare Containers run without root privileges, so Docker must run in # rootless mode. The Sandbox SDK server is copied into this image so the diff --git a/services/cloud-agent-next/src/kilo/devcontainer.ts b/services/cloud-agent-next/src/kilo/devcontainer.ts index 325ddf72f5..dbc600fa67 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.ts @@ -148,7 +148,7 @@ function buildDevContainerTrustEnv(sessionHome: string): Record * `wrangler.jsonc#image_vars` so the kilo running in the dev container * matches the one we use on the outer sandbox. */ -export const KILO_CLI_VERSION = '7.3.54'; +export const KILO_CLI_VERSION = '7.3.63'; const DEVCONTAINER_RUNTIME_BUN_VERSION = '1.3.14'; const DEVCONTAINER_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts b/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts index 27b39fdff9..27a294961c 100644 --- a/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts +++ b/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts @@ -17,7 +17,7 @@ export type SlashCommandInfo = { * * Regenerate with `pnpm --filter cloud-agent-next update-default-slash-commands`. */ -export const DEFAULT_SLASH_COMMANDS_SOURCE = 'kilo@7.3.54'; +export const DEFAULT_SLASH_COMMANDS_SOURCE = 'kilo@7.3.63'; /** * Default slash command catalog used when no live wrapper-reported catalog is diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 5361132883..097bbd64a2 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -161,7 +161,7 @@ "disk_mb": 20000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 200, "rollout_active_grace_period": 1800, @@ -175,7 +175,7 @@ "disk_mb": 10000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 150, "rollout_active_grace_period": 1800, @@ -189,7 +189,7 @@ "disk_mb": 10000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 20, "rollout_active_grace_period": 1800, @@ -203,7 +203,7 @@ "disk_mb": 8000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 500, "rollout_active_grace_period": 1800, @@ -217,7 +217,7 @@ "disk_mb": 20000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 750, "rollout_active_grace_period": 1800, @@ -231,7 +231,7 @@ "disk_mb": 10000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 300, "rollout_active_grace_period": 1800, @@ -245,7 +245,7 @@ "disk_mb": 8000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 1500, "rollout_active_grace_period": 1800, @@ -456,7 +456,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 10, "rollout_active_grace_period": 60, @@ -466,7 +466,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 2, "rollout_active_grace_period": 60, @@ -476,7 +476,7 @@ "image": "./Dockerfile.dind", "instance_type": "standard-3", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 2, "rollout_active_grace_period": 60, @@ -490,7 +490,7 @@ "disk_mb": 8000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 2, "rollout_active_grace_period": 60, @@ -500,7 +500,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 10, "rollout_active_grace_period": 60, @@ -510,7 +510,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 2, "rollout_active_grace_period": 60, @@ -524,7 +524,7 @@ "disk_mb": 8000, }, "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.54", + "KILOCODE_CLI_VERSION": "7.3.63", }, "max_instances": 2, "rollout_active_grace_period": 60,