From 97c65e5b0cb03ab7393a9e3d1295738664a47d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:46:02 +0200 Subject: [PATCH 1/8] =?UTF-8?q?fix(mobile):=20PR=20review=20sheets=20?= =?UTF-8?q?=E2=80=94=20visible=20header,=20no=20stray=20chevron,=20keyboar?= =?UTF-8?q?d-safe=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-native-screens formSheet only honors a pinned header when the screen content's direct children are [header, ScrollView]; the old View → ScrollView + sticky-footer tree let the ScrollView go full-bleed and overpaint the header. The stray leading-edge ‹ was the parent PR screen's Go back showing through a transparent top band (opaque sheet chrome + close affordance fixes it). Footer CTAs move inside the ScrollView as trailing content with automaticallyAdjustKeyboardInsets + AppAwareKeyboardPaddingView, plus density passes so Merge/Submit/comment CTAs stay above the keyboard at both detents without scrolling. --- .../pr-review/merge/pr-merge-sheet-parts.tsx | 239 +++++++++++++++--- .../pr-review/merge/pr-merge-sheet.tsx | 129 ++++------ .../pr-review/pr-form-sheet-chrome.tsx | 77 ++++++ .../pr-review-comment-composer-parts.tsx | 39 ++- .../pr-review-comment-composer-screen.tsx | 82 +++--- .../pr-review/pr-review-comment-composer.tsx | 136 ++++++---- .../pr-review/pr-review-merge-screen.tsx | 71 +++--- .../pr-review-pending-comment-row.tsx | 18 +- .../pr-review-review-submit-screen.tsx | 71 +++--- .../components/pr-review/pr-review-submit.tsx | 228 +++++++++++------ 10 files changed, 719 insertions(+), 371 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx 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/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'; } From 80b0f1b2d5bb7c664fe20062b0ea11cd28add452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:46:25 +0200 Subject: [PATCH 3/8] fix(mobile): new-session composer bottom action row Paperclip and voice button no longer flank the multiline input at mid-height. The input sits full-width at the top of the card and one bottom action row holds the paperclip (leading) and voice toggle (trailing); when voice input is unavailable the row shows the paperclip only. Attachment strip, voice status line, and ChatToolbar unchanged. --- .../components/agents/new-session-prompt.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) 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 ? ( From 14b15a2c8d9777bac95348237c83b9e49007b7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:46:25 +0200 Subject: [PATCH 4/8] fix(mobile): never show the auto model in the Models list Render-only filter: a Models row is hidden iff providerID is kilo and modelID starts with kilo-auto/. Totals and the Subagents residual are still computed over all steps, and section visibility plus the Models (N) count derive from the filtered rows plus the residual, so an auto-only session with no residual hides the section entirely. --- .../agents/session-context-sheet.tsx | 11 +- ...ssion-cost-breakdown-models-filter.test.ts | 186 ++++++++++++++++++ .../agents/session-cost-breakdown.ts | 32 +++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-cost-breakdown-models-filter.test.ts 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) { From 4a0df85586125748828f1a42f41f630fc199f1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:46:43 +0200 Subject: [PATCH 5/8] fix(cloud-agent-next): bump sandbox CLI to 7.3.63 for routed-model stamps The 7.3.54 pin predates the routed-model stamp feature (first released in v7.3.63), so step-finish parts for kilo-auto/* sessions arrived without a model stamp and the mobile Models list had nothing concrete to show. Bumped in lockstep across Dockerfiles, wrangler image_vars, and KILO_CLI_VERSION (devcontainer.test.ts asserts the lockstep). Re-proved live: a fresh kilo-auto/efficient session now stamps step-finish.model with the concrete routed id at the ingest boundary and in the app-facing payload. @kilocode/sdk stays at 7.3.54 (verified faithful in the ingest path). The slash-command SOURCE marker follows the pin; the catalog body regeneration is a follow-up (generator's kilo serve step hung locally). --- services/cloud-agent-next/Dockerfile | 2 +- services/cloud-agent-next/Dockerfile.dev | 2 +- services/cloud-agent-next/Dockerfile.dind | 2 +- .../cloud-agent-next/src/kilo/devcontainer.ts | 2 +- .../default-slash-commands.generated.ts | 2 +- services/cloud-agent-next/wrangler.jsonc | 28 +++++++++---------- 6 files changed, 19 insertions(+), 19 deletions(-) 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, From 15108b5fbe2c7e17a2337c00f433c422a9d577f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:52:41 +0200 Subject: [PATCH 6/8] fix(mobile): suppress duplicate preparation footer; drop per-message model label While the cloud agent is preparing, the fixed footer row above the composer duplicated the in-transcript PreparationGroup. The new shouldShowSessionFooterRow gate suppresses the footer only while the transcript carries a running (non-no-op) preparation item; a stale completed or failed group never suppresses it, and with no live group the footer keeps rendering so there is never a blank progress window. Also removes the per-message model label from the transcript (label, wiring, and computeMessageModelLabels); message info moves to a long-tap details sheet. resolveMessageDisplayModel is kept for that sheet. --- .../src/components/agents/message-bubble.tsx | 17 -- .../agents/message-model-label.test.ts | 167 +----------------- .../components/agents/message-model-label.ts | 68 +------ .../agents/session-detail-content.tsx | 42 ++--- .../agents/session-working-state.test.ts | 115 ++++++++++++ .../agents/session-working-state.ts | 33 ++++ 6 files changed, 179 insertions(+), 263 deletions(-) diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index 6919fc7406..6febcfa4b5 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -24,13 +24,6 @@ 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; }; export function MessageBubble({ @@ -41,7 +34,6 @@ export function MessageBubble({ defaultReasoningExpanded, onOpenChildSession, deliveryState, - modelLabel, }: Readonly) { const isUser = message.info.role === 'user'; const { copyMessage } = useMessageCopy(); @@ -139,15 +131,6 @@ export function MessageBubble({ onOpenChildSession={onOpenChildSession} /> ))} - {modelLabel ? ( - - {modelLabel} - - ) : null} ); 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/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index f8c9d9c0b4..8772e23cae 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -15,7 +15,6 @@ 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 { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector'; import { PermissionCard } from '@/components/agents/permission-card'; import { QuestionCard } from '@/components/agents/question-card'; @@ -40,6 +39,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'; @@ -333,16 +333,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 +366,6 @@ export function SessionDetailContent({ defaultReasoningExpanded={reasoningDefaultExpanded} onOpenChildSession={handleOpenChildSession} deliveryState={deliveryState} - modelLabel={ - item.message.info.role === 'assistant' - ? messageModelLabels.get(item.message.info.id) - : undefined - } /> ); }, @@ -391,7 +376,6 @@ export function SessionDetailContent({ reasoningDefaultExpanded, handleOpenChildSession, pendingMessages, - messageModelLabels, ] ); @@ -452,10 +436,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'; @@ -692,8 +691,9 @@ export function SessionDetailContent({ content-size changes during streaming cannot reposition it. Gated on has-messages so the empty/connecting path (which renders the centered status indicator inside `renderContent`) - does not double-render. */} - {messages.length > 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; +} From e62f835513362e842da57ad41eba40e32fa1137a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 22:54:06 +0200 Subject: [PATCH 7/8] feat(mobile): long-tap message details sheet Long-press on a user or assistant message now opens a details sheet (Modal pageSheet with SheetHeader chrome) instead of the copy ActionSheet. The sheet shows a Copy message action (immediate clipboard write through the shared performCopy extracted from use-message-copy, hidden when the message has no copyable text), title-cased Role, Sent time formatted from the epoch created timestamp, a Model row for assistant messages (routed-first via resolveMessageDisplayModel, friendly catalog name, hidden only when unresolvable), and a Cost & tokens block for assistant messages (hidden when cost and all five token values are zero). The VoiceOver/TalkBack rotor copy action keeps the existing useMessageCopy behavior, and compaction-only rows stay non-interactive. --- .../components/agents/message-bubble.test.ts | 72 +++++ .../src/components/agents/message-bubble.tsx | 16 +- .../agents/message-details-content.ts | 129 ++++++++ .../components/agents/message-details-copy.ts | 12 + .../agents/message-details-sheet.test.ts | 290 ++++++++++++++++++ .../agents/message-details-sheet.tsx | 127 ++++++++ .../agents/session-detail-content.tsx | 14 +- .../agents/use-message-copy.test.ts | 56 ++++ .../src/components/agents/use-message-copy.ts | 7 +- 9 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/components/agents/message-details-content.ts create mode 100644 apps/mobile/src/components/agents/message-details-copy.ts create mode 100644 apps/mobile/src/components/agents/message-details-sheet.test.ts create mode 100644 apps/mobile/src/components/agents/message-details-sheet.tsx create mode 100644 apps/mobile/src/components/agents/use-message-copy.test.ts 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 6febcfa4b5..61bbd66d94 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -24,8 +24,12 @@ type MessageBubbleProps = { onOpenChildSession?: OpenChildSession; /** Per-user-message delivery state. v1 surfaces only a "Queued" badge. */ deliveryState?: MessageDeliveryState; + /** 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, @@ -34,18 +38,18 @@ export function MessageBubble({ defaultReasoningExpanded, onOpenChildSession, deliveryState, + 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') { @@ -77,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} > @@ -116,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} > 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/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 8772e23cae..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,6 +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 { 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'; @@ -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(); @@ -366,6 +368,7 @@ export function SessionDetailContent({ defaultReasoningExpanded={reasoningDefaultExpanded} onOpenChildSession={handleOpenChildSession} deliveryState={deliveryState} + onLongPressDetails={setDetailsMessage} /> ); }, @@ -652,6 +655,15 @@ export function SessionDetailContent({ /> ) : null} + { + setDetailsMessage(null); + }} + /> + {childSession ? ( ({ + 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); From d7e2668cc7912ba378dc3d85a5c2c8393d95053e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 23:43:57 +0200 Subject: [PATCH 8/8] chore: retrigger kilobot review for merged head