diff --git a/apps/mobile/src/components/agents/child-session-card-state.test.ts b/apps/mobile/src/components/agents/child-session-card-state.test.ts index dc81528f1c..ed1cb163b7 100644 --- a/apps/mobile/src/components/agents/child-session-card-state.test.ts +++ b/apps/mobile/src/components/agents/child-session-card-state.test.ts @@ -107,7 +107,7 @@ function makeAssistantMessage(parts: Part[], id = 'msg-1'): StoredMessage { describe('getChildSessionCardState', () => { it.each([ ['pending', 'Waiting for activity'], - ['running', 'Waiting for activity'], + ['running', 'Thinking'], ['completed', ''], ['error', ''], ] as const)( @@ -324,6 +324,18 @@ describe('getChildSessionCardState', () => { }); }); + it('reads Thinking while a reasoning part streams behind an empty text placeholder', () => { + const part = makeTaskPart('running', { subagent_type: 'Thinker', description: 'Reason' }); + const messages = [ + makeAssistantMessage([makeReasoningPart('stepping through the problem'), makeTextPart('')]), + ]; + expect(getChildSessionCardState(part, messages)).toEqual({ + agentName: 'Thinker', + taskName: 'Reason', + latestActivity: 'Thinking', + }); + }); + it('prefers a newer text part over an older completed tool part', () => { const part = makeTaskPart('running', { subagent_type: 'Agent', description: 'Work' }); const olderTool = makeToolPart('read', { diff --git a/apps/mobile/src/components/agents/child-session-card-state.ts b/apps/mobile/src/components/agents/child-session-card-state.ts index 2cb0ceb939..15fd8c73e1 100644 --- a/apps/mobile/src/components/agents/child-session-card-state.ts +++ b/apps/mobile/src/components/agents/child-session-card-state.ts @@ -7,7 +7,7 @@ import { import { i18n } from '@/i18n'; -import { computeStatus } from './compute-status'; +import { computeStatus, lastActivePart } from './compute-status'; import { isToolPart } from './part-types'; import { getFilename, truncateText } from './tool-card-utils'; @@ -64,16 +64,11 @@ function getToolContext(p: ToolPart): string | undefined { return undefined; } -function findLatestAssistantPart(messages: StoredMessage[]): Part | undefined { +function findLatestAssistantParts(messages: StoredMessage[]): readonly Part[] | undefined { for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; - if (msg?.info.role === 'assistant') { - for (let j = msg.parts.length - 1; j >= 0; j -= 1) { - const part = msg.parts[j]; - if (part) { - return part; - } - } + if (msg?.info.role === 'assistant' && msg.parts.length > 0) { + return msg.parts; } } return undefined; @@ -95,14 +90,23 @@ export function getChildSessionCardState( if (part.state.status === 'completed' || part.state.status === 'error') { return ''; } - const latestPart = findLatestAssistantPart(childMessages); - if (!latestPart) { - return i18n.t('agentChat.childSession.waitingForActivity'); - } - if (isToolPart(latestPart)) { - return { tool: latestPart.tool, context: getToolContext(latestPart) }; + const assistantParts = findLatestAssistantParts(childMessages); + if (assistantParts) { + const latestPart = lastActivePart(assistantParts); + if (latestPart) { + if (isToolPart(latestPart)) { + return { tool: latestPart.tool, context: getToolContext(latestPart) }; + } + return computeStatus(latestPart); + } } - return computeStatus(latestPart); + // A running subagent without a loaded child transcript is still working, so + // the card shows the same "Thinking" label the composer spinner uses while + // it streams reasoning. A pending task has no child session yet; it is + // queued and genuinely waiting to start. + return part.state.status === 'running' + ? i18n.t('agentChat.partDetail.thinking') + : i18n.t('agentChat.childSession.waitingForActivity'); })(); return { agentName, taskName, latestActivity }; diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index 2036eb5dee..777b2165c8 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -22,6 +22,7 @@ import { import { getChildSessionModelLabel } from './child-session-model'; import { ChildSessionModelLabel } from './child-session-model-label'; import { MessageErrorBoundary } from './message-error-boundary'; +import { partRendersContent } from './message-visibility'; import { PartDetailSheetHost } from './part-detail-sheet-host'; import { getChildSessionSheetState } from './child-session-sheet-state'; import { SessionMessageList } from './session-message-list'; @@ -34,6 +35,13 @@ type ChildSessionSheetProps = { sessionId: string; title: string; getChildMessages: (sessionId: string) => StoredMessage[]; + /** + * Resolves the messages that derive status indicators: the footer + * working-indicator label and the nested task cards' activity label. + * Defaults to `getChildMessages`. The session page passes the raw transcript + * here so hiding thinking rows never changes "Thinking". + */ + getIndicatorMessages?: (sessionId: string) => StoredMessage[]; hydrationState: ChildSessionHydrationState; sessionError: string | null; isStreaming: boolean; @@ -56,6 +64,7 @@ export function ChildSessionSheet({ sessionId, title, getChildMessages, + getIndicatorMessages = getChildMessages, hydrationState, sessionError, isStreaming, @@ -72,6 +81,11 @@ export function ChildSessionSheet({ modelOptions, }: Readonly) { const messages = getChildMessages(sessionId); + const indicatorMessages = getIndicatorMessages(sessionId); + // A reasoning-only message keeps its place in `messages` so the sheet stays in + // the content state and the footer spinner reads "Thinking", but it renders no + // row. Drop it from the list so its padded wrapper cannot leave an empty row. + const rowMessages = messages.filter(message => message.parts.some(partRendersContent)); const state = getChildSessionSheetState(hydrationState, messages.length, sessionError); const modelLabel = getChildSessionModelLabel(messages, modelOptions ?? []); const { t } = useTranslation(); @@ -118,7 +132,7 @@ export function ChildSessionSheet({ ) : null} message.info.id} hasOlderMessages={hasOlderMessages} isLoadingOlderMessages={isLoadingOlderMessages} @@ -131,7 +145,10 @@ export function ChildSessionSheet({ )} - ListFooterComponent={} + ListFooterComponent={ + + } contentBottomInset={sheetBottomInset} /> diff --git a/apps/mobile/src/components/agents/compute-status.test.ts b/apps/mobile/src/components/agents/compute-status.test.ts index d7b1f88a00..ca0e3b036e 100644 --- a/apps/mobile/src/components/agents/compute-status.test.ts +++ b/apps/mobile/src/components/agents/compute-status.test.ts @@ -1,7 +1,7 @@ import { type ReasoningPart, type TextPart, type ToolPart } from '@kilocode/cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; -import { computeStatus, SNAPSHOT_PROGRESS_STATUS } from './compute-status'; +import { computeMessageStatus, computeStatus, SNAPSHOT_PROGRESS_STATUS } from './compute-status'; function makeTextPart(text: string, synthetic?: boolean): TextPart { const part: TextPart = { @@ -69,3 +69,22 @@ describe('computeStatus', () => { expect(computeStatus(makeToolPart('unknown-tool'))).toBe('Considering next steps'); }); }); + +describe('computeMessageStatus', () => { + it('reads Thinking while the reasoning part streams behind an empty text placeholder', () => { + // opencode creates the response text part before any token arrives; its id + // sorts after the reasoning part, which used to mask the Thinking label. + expect(computeMessageStatus([makeReasoningPart(), makeTextPart('')])).toBe('Thinking'); + }); + + it('reads Writing response once the text part has content', () => { + expect(computeMessageStatus([makeReasoningPart(), makeTextPart('Hello')])).toBe( + 'Writing response' + ); + }); + + it('falls back to Considering next steps when only empty placeholders exist', () => { + expect(computeMessageStatus([makeTextPart('')])).toBe('Considering next steps'); + expect(computeMessageStatus([])).toBe('Considering next steps'); + }); +}); diff --git a/apps/mobile/src/components/agents/compute-status.ts b/apps/mobile/src/components/agents/compute-status.ts index befbfb5d91..636ae3b454 100644 --- a/apps/mobile/src/components/agents/compute-status.ts +++ b/apps/mobile/src/components/agents/compute-status.ts @@ -41,3 +41,27 @@ export function computeStatus(part: Part): string { } return i18n.t('agentChat.computeStatus.consideringNextSteps'); } + +/** + * opencode pre-creates an empty `text` part for the response block before any + * token arrives, and its id sorts after the reasoning part (a stream looks + * like `[step-start, reasoning, text(0)]`). Reading the raw last part would + * therefore label the whole reasoning stream "Writing response", and the + * spinner would never read "Thinking". Skip empty text placeholders and + * describe the last part that actually carries activity. + */ +export function lastActivePart(parts: readonly Part[]): Part | undefined { + for (let i = parts.length - 1; i >= 0; i -= 1) { + const part = parts[i]; + if (part !== undefined && !(part.type === 'text' && part.text === '')) { + return part; + } + } + return undefined; +} + +/** Spinner label for an assistant message's parts. */ +export function computeMessageStatus(parts: readonly Part[]): string { + const part = lastActivePart(parts); + return part ? computeStatus(part) : i18n.t('agentChat.computeStatus.consideringNextSteps'); +} diff --git a/apps/mobile/src/components/agents/part-types.test.ts b/apps/mobile/src/components/agents/part-types.test.ts index 78b7236161..02e14042be 100644 --- a/apps/mobile/src/components/agents/part-types.test.ts +++ b/apps/mobile/src/components/agents/part-types.test.ts @@ -2,15 +2,19 @@ import { type FilePart, type PatchPart, type ReasoningPart, + type StoredMessage, type TextPart, + type ToolPart, } from '@kilocode/cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; +import { assistantMessage } from './message-bubble-test-utils'; import { isPartStreaming, isPatchPart, isSnapshotProgressPart, shouldRenderReasoningPart, + withoutReasoningParts, } from './part-types'; function makeReasoningPart(text: string, ended = true): ReasoningPart { @@ -39,6 +43,29 @@ function makeTextPart(text: string, synthetic?: boolean): TextPart { return part; } +function makeToolPart(): ToolPart { + return { + id: 'tool-1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + tool: 'read', + callID: 'call-1', + state: { + status: 'completed', + input: { filePath: 'src/a.ts' }, + output: 'contents', + title: 'Read', + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; +} + +function storedMessage(id: string, parts: StoredMessage['parts']): StoredMessage { + return { info: assistantMessage(id).info, parts }; +} + describe('isSnapshotProgressPart', () => { it('is true for a synthetic text part whose text includes Initializing snapshot', () => { const part = makeTextPart('⠋ Initializing snapshot…', true); @@ -146,3 +173,37 @@ describe('shouldRenderReasoningPart', () => { expect(shouldRenderReasoningPart(part, false)).toBe(false); }); }); + +describe('withoutReasoningParts', () => { + it('removes reasoning while keeping text and tool parts', () => { + const text = makeTextPart('answer'); + const tool = makeToolPart(); + const message = storedMessage('m1', [makeReasoningPart('thinking'), text, tool]); + + const result = withoutReasoningParts([message]); + + expect(result[0]?.parts).toEqual([text, tool]); + expect(result[0]?.parts.some(part => part.type === 'reasoning')).toBe(false); + }); + + it('returns the same array reference when no message has reasoning', () => { + const messages: StoredMessage[] = [ + storedMessage('m1', [makeTextPart('a')]), + storedMessage('m2', [makeToolPart()]), + ]; + + expect(withoutReasoningParts(messages)).toBe(messages); + }); + + it('keeps message identity for unchanged messages', () => { + const unchanged = storedMessage('m1', [makeTextPart('a')]); + const changed = storedMessage('m2', [makeReasoningPart('thinking'), makeTextPart('b')]); + + const result = withoutReasoningParts([unchanged, changed]); + + expect(result[0]).toBe(unchanged); + expect(result[1]).not.toBe(changed); + expect(result[1]?.info).toBe(changed.info); + expect(result[1]?.parts.map(part => part.type)).toEqual(['text']); + }); +}); diff --git a/apps/mobile/src/components/agents/part-types.ts b/apps/mobile/src/components/agents/part-types.ts index 05b6b8f76f..0f697c8b3e 100644 --- a/apps/mobile/src/components/agents/part-types.ts +++ b/apps/mobile/src/components/agents/part-types.ts @@ -4,6 +4,7 @@ import { type Part, type PatchPart, type ReasoningPart, + type StoredMessage, type TextPart, type ToolPart, } from '@kilocode/cloud-agent-sdk'; @@ -43,6 +44,26 @@ export function isReasoningPart(part: Part): part is ReasoningPart { return part.type === 'reasoning'; } +/** + * Returns the messages with every reasoning part removed, for the + * "Hide thinking details" option. A message that has no reasoning part keeps + * its identity, and the input array itself is returned when nothing changed, + * so memoized consumers do not churn when thinking is already absent. + */ +export function withoutReasoningParts(messages: readonly StoredMessage[]): StoredMessage[] { + const next = messages.map(message => { + const parts = message.parts.filter(part => !isReasoningPart(part)); + if (parts.length === message.parts.length) { + return message; + } + return { ...message, parts }; + }); + const changed = next.some((message, index) => message !== messages[index]); + // Hand back the input array itself when nothing was removed. Callers only + // read the result, so widening the readonly view is safe. + return changed ? next : (messages as StoredMessage[]); +} + export function isCompactionPart(part: Part): part is CompactionPart { return part.type === 'compaction'; } diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index 814af354c7..bcc25ffd6c 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Keep the detail trigger and real SDK request regressions with their shared screen fixture. */ /* eslint-disable typescript-eslint/no-deprecated -- The repository uses react-test-renderer for DOM-free native component tests. */ -import { type ComponentProps, createElement, Fragment } from 'react'; +import { type ComponentProps, createElement, Fragment, type ReactNode } from 'react'; import { createStore, Provider } from 'jotai'; import { QueryClientProvider } from '@tanstack/react-query'; import { act, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; @@ -10,6 +10,7 @@ import { createSessionManager, createUserWebConnection, type KiloSessionId, + type ReasoningPart, type SessionGoal, type SessionManager, type SessionSnapshotPageOutcome, @@ -23,11 +24,13 @@ import { ChildSessionSection } from '@/components/agents/child-session-section'; import { ChildSessionModelLabel } from '@/components/agents/child-session-model-label'; import { ChildSessionSheet } from '@/components/agents/child-session-sheet'; import { getTaskToolSessionId } from '@/components/agents/child-session-card-state'; +import { MessageBubble } from '@/components/agents/message-bubble'; import { assistantMessage } from '@/components/agents/message-bubble-test-utils'; import { SessionDetailContent } from '@/components/agents/session-detail-content'; import { SessionGoalSection } from '@/components/agents/session-goal-section'; import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton'; -import { type SessionMessageList } from '@/components/agents/session-message-list'; +import { SessionMessageList } from '@/components/agents/session-message-list'; +import { WorkingIndicator } from '@/components/agents/working-indicator'; import { resolveSendAttachmentKind, shouldRefuseSilentAttachmentDrop, @@ -40,6 +43,7 @@ import { i18n } from '@/i18n'; import { renderWithProviders } from '@/test/render-with-providers'; const managerSlot = vi.hoisted(() => ({ current: null as SessionManager | null })); +const hideThinking = vi.hoisted(() => ({ current: false, loaded: true })); vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshControl' })); vi.mock('@/components/agents/session-provider', () => ({ @@ -166,13 +170,13 @@ vi.mock('@/components/agents/session-detail-skeleton', () => ({ vi.mock('@/components/agents/transcript-time-marker', () => ({ TranscriptTimeMarker: 'TranscriptTimeMarker', })); -vi.mock('@/components/agents/working-indicator', () => ({ WorkingIndicator: 'WorkingIndicator' })); vi.mock('@/components/agents/compaction-separator', () => ({ CompactionSeparator: 'CompactionSeparator', })); vi.mock('@/components/agents/file-part-renderer', () => ({ FilePartRenderer: 'FilePartRenderer' })); vi.mock('@/components/agents/reasoning-part-renderer', () => ({ - ReasoningPartRenderer: 'ReasoningPartRenderer', + ReasoningPartRenderer: ({ text }: { text: string }) => + createElement('ReasoningPartRenderer', null, createElement('Text', null, text)), })); vi.mock('@/components/agents/text-part-renderer', () => ({ TextPartRenderer: ({ text }: { text: string }) => createElement('Text', null, text), @@ -193,7 +197,8 @@ vi.mock('@/components/agents/session-message-list', () => ({ { key: props.keyExtractor(item) }, props.renderItem({ item, index, target: 'Cell' }) ) - ) + ), + props.ListFooterComponent as ReactNode ); }, })); @@ -262,6 +267,12 @@ vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ useReasoningPreference: () => ({ defaultExpanded: false }), })); +vi.mock('@/lib/hooks/use-hide-thinking-preference', () => ({ + useHideThinkingPreference: () => ({ + hideThinking: hideThinking.current, + hasLoaded: hideThinking.loaded, + }), +})); vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ useKeepScreenOnPreference: () => ({ keepScreenOn: false, hasLoaded: true }), })); @@ -389,6 +400,8 @@ function page( beforeEach(() => { navigationRoutes.splice(0, navigationRoutes.length, 'session-detail'); openRenameModal.mockClear(); + hideThinking.current = false; + hideThinking.loaded = true; goalMountOptions = {}; globalContext.organizationId = 'global-org'; globalContext.setOrganizationId.mockClear(); @@ -543,6 +556,10 @@ function renderedText(node: ReactTestInstance) { .join('\n'); } +function reasoningRenderers(renderer: ReactTestRenderer) { + return renderer.root.findAll(node => Object.is(node.type, 'ReasoningPartRenderer')); +} + function pressHeaderBack(renderer: ReactTestRenderer) { const { onPress } = renderer.root.findByProps({ accessibilityLabel: 'Go back' }).props as { onPress: () => void; @@ -750,25 +767,25 @@ describe('child transcript requests', () => { status: 'completed', text: 'Researcher\nTask ses-selected\ncompleted', textRows: 3, - waiting: false, + activity: null, }, { sessionId: kiloId('ses-sibling-0'), status: 'running', - text: 'Researcher\nTask ses-sibling-0\nWaiting for activity\nrunning', + text: 'Researcher\nTask ses-sibling-0\nThinking\nrunning', textRows: 4, - waiting: true, + activity: 'Thinking', }, { sessionId: kiloId('ses-sibling-1'), status: 'error', text: 'Researcher\nTask ses-sibling-1\nerror', textRows: 3, - waiting: false, + activity: null, }, ] as const)( 'renders the $status card without fetching a child transcript for labels', - async ({ sessionId, status, text, textRows, waiting }) => { + async ({ sessionId, status, text, textRows, activity }) => { const view = await mountDetails(); const card = cardFor(view.renderer, sessionId); const button = card.findByProps({ accessibilityRole: 'button' }).props as ComponentProps< @@ -786,7 +803,10 @@ describe('child transcript requests', () => { expect(button.accessibilityLabel).toContain('Researcher'); expect(button.accessibilityLabel).toContain(`Task ${sessionId}`); expect(button.accessibilityLabel).toContain(status); - expect(button.accessibilityLabel?.includes('Waiting for activity')).toBe(waiting); + expect(button.accessibilityLabel?.includes('Waiting for activity')).toBe(false); + if (activity) { + expect(button.accessibilityLabel).toContain(activity); + } expect(view.renderer.root.findAllByType(ChildSessionModelLabel)).toHaveLength(0); expect(view.requestedIds()).toEqual([ROOT_ID]); } @@ -831,7 +851,7 @@ describe('child transcript requests', () => { expect(selectedCard.findAllByType(ChildSessionModelLabel)).toHaveLength(1); const nestedCard = cardFor(view.renderer, nestedId); expect(renderedText(nestedCard)).toBe( - `Researcher\nTask ${nestedId}${isRunning ? '\nWaiting for activity' : ''}\n${status}` + `Researcher\nTask ${nestedId}${isRunning ? '\nThinking' : ''}\n${status}` ); expect(nestedCard.findAll(node => (node.type as string) === 'Text')).toHaveLength( isRunning ? 4 : 3 @@ -845,7 +865,10 @@ describe('child transcript requests', () => { }); expect(nestedButton.accessibilityLabel).toContain(`Task ${nestedId}`); expect(nestedButton.accessibilityLabel).toContain(status); - expect(nestedButton.accessibilityLabel?.includes('Waiting for activity')).toBe(isRunning); + expect(nestedButton.accessibilityLabel?.includes('Waiting for activity')).toBe(false); + if (isRunning) { + expect(nestedButton.accessibilityLabel).toContain('Thinking'); + } expect(view.requestedIds()).toEqual([ROOT_ID, selectedId]); pressCard(view.renderer, nestedId); @@ -986,6 +1009,187 @@ describe('child transcript requests', () => { }); }); +describe('hide thinking preference', () => { + function partMessage(id: string, parts: StoredMessage['parts']): StoredMessage { + return { info: { ...assistantMessage(id).info, sessionID: ROOT_ID }, parts }; + } + + function reasoningPart(id: string, messageID: string): ReasoningPart { + return { + id, + sessionID: ROOT_ID, + messageID, + type: 'reasoning', + text: 'hidden chain of thought', + time: { start: 1, end: 2 }, + }; + } + + function reasoningAndTextMessage(): StoredMessage { + const id = 'msg-think'; + return partMessage(id, [ + reasoningPart('reasoning-1', id), + stubTextPart({ id: `text-${id}`, sessionID: ROOT_ID, messageID: id, text: 'Visible answer' }), + ]); + } + + it('renders thinking when the option is off', async () => { + hideThinking.current = false; + const view = await mountDetails([reasoningAndTextMessage()]); + + expect(reasoningRenderers(view.renderer)).toHaveLength(1); + expect(renderedText(view.renderer.root)).toContain('Visible answer'); + }); + + it('hides thinking but keeps the text when the option is on', async () => { + hideThinking.current = true; + const view = await mountDetails([reasoningAndTextMessage()]); + + expect(reasoningRenderers(view.renderer)).toHaveLength(0); + expect(renderedText(view.renderer.root)).toContain('Visible answer'); + }); + + it('does not paint thinking before the preference resolves on cold start', async () => { + hideThinking.current = false; + hideThinking.loaded = false; + const view = await mountDetails([reasoningAndTextMessage()]); + + expect(reasoningRenderers(view.renderer)).toHaveLength(0); + expect(renderedText(view.renderer.root)).toContain('Visible answer'); + }); + + it('drops a reasoning-only message from the transcript but keeps it in the working indicator', async () => { + hideThinking.current = true; + const message = partMessage('msg-think-only', [ + reasoningPart('reasoning-only', 'msg-think-only'), + ]); + const view = await mountDetails([message]); + act(() => { + view.store.set( + view.manager.atoms.statusIndicator, + { type: 'info', message: 'Session status', timestamp: 0 } + ); + }); + + expect(reasoningRenderers(view.renderer)).toHaveLength(0); + expect(view.renderer.root.findAllByType(MessageBubble)).toHaveLength(0); + expect( + view.renderer.root.findAll(node => Object.is(node.type, 'TranscriptTimeMarker')) + ).toHaveLength(0); + expect(view.renderer.root.findAllByType(EmptyState)).toHaveLength(0); + + const indicator = view.renderer.root.findByType(WorkingIndicator); + const indicatorMessages = indicator.props.messages as StoredMessage[]; + expect(indicatorMessages.some(candidate => candidate.info.id === 'msg-think-only')).toBe(true); + expect( + indicatorMessages.some(candidate => candidate.parts.some(part => part.type === 'reasoning')) + ).toBe(true); + }); + + // The running child's sheet is the surface the composer spinner rule also + // covers: the option hides the thinking row inside the sheet without changing + // the spinner label, which still derives from the reasoning part. + const RUNNING_CHILD = kiloId('ses-sibling-0'); + + function childReasoningMessage(sessionId: KiloSessionId): StoredMessage { + const id = `msg-${sessionId}`; + return { + info: { ...assistantMessage(id).info, sessionID: sessionId }, + parts: [ + { + id: `reasoning-${sessionId}`, + sessionID: sessionId, + messageID: id, + type: 'reasoning', + text: 'hidden chain of thought', + time: { start: 1, end: 2 }, + }, + ], + }; + } + + function childTextMessage(sessionId: KiloSessionId, text: string): StoredMessage { + const id = `msg-${sessionId}`; + return { + info: { ...assistantMessage(id).info, sessionID: sessionId }, + parts: [stubTextPart({ id: `text-${sessionId}`, sessionID: sessionId, messageID: id, text })], + }; + } + + async function openRunningChildSheet(hide: boolean) { + hideThinking.current = hide; + const view = await mountDetails(); + pressCard(view.renderer, RUNNING_CHILD); + return view; + } + + function childSheetText(view: Awaited>) { + return renderedText(view.renderer.root.findByType(ChildSessionSheet)); + } + + it('keeps the subagent sheet spinner on Thinking while the reasoning row is hidden', async () => { + const view = await openRunningChildSheet(true); + await view.respond(RUNNING_CHILD, [childReasoningMessage(RUNNING_CHILD)]); + + const sheetText = childSheetText(view); + expect(sheetText).toContain('Thinking'); + expect(sheetText).not.toContain('hidden chain of thought'); + }); + + it('keeps the in-transcript task card on Thinking while the reasoning row is hidden', async () => { + const view = await openRunningChildSheet(true); + await view.respond(RUNNING_CHILD, [childReasoningMessage(RUNNING_CHILD)]); + + expect(renderedText(cardFor(view.renderer, RUNNING_CHILD))).toContain('Thinking'); + }); + + it('renders no empty padded row for a reasoning-only child message', async () => { + const view = await openRunningChildSheet(true); + await view.respond(RUNNING_CHILD, [childReasoningMessage(RUNNING_CHILD)]); + + const sheet = view.renderer.root.findByType(ChildSessionSheet); + const list = sheet.findByType(SessionMessageList); + expect(list.props.items).toHaveLength(0); + expect(sheet.findAllByType(EmptyState)).toHaveLength(0); + expect(list.props.ListFooterComponent).toBeDefined(); + }); + + it('keeps a nested task card on Thinking while the reasoning row is hidden', async () => { + const runningNested = kiloId('ses-nested-running'); + const view = await openRunningChildSheet(true); + const selected = taskMessage(RUNNING_CHILD, [NESTED_ID, runningNested]); + selected.parts.push(...childMessage(RUNNING_CHILD, 'Selected child row').parts); + await view.respond(RUNNING_CHILD, [selected]); + + pressCard(view.renderer, runningNested); + await view.respond(runningNested, [childReasoningMessage(runningNested)]); + pressCard(view.renderer, RUNNING_CHILD); + + expect(renderedText(cardFor(view.renderer, runningNested))).toContain('Thinking'); + }); + + it('shows the subagent reasoning row and the Thinking spinner when the option is off', async () => { + const view = await openRunningChildSheet(false); + await view.respond(RUNNING_CHILD, [childReasoningMessage(RUNNING_CHILD)]); + + const sheetText = childSheetText(view); + expect(sheetText).toContain('Thinking'); + expect(sheetText).toContain('hidden chain of thought'); + }); + + it.each([true, false])( + 'shows no reasoning row and the non-thinking spinner label in the subagent sheet (option %s)', + async hide => { + const view = await openRunningChildSheet(hide); + await view.respond(RUNNING_CHILD, [childTextMessage(RUNNING_CHILD, 'Only text')]); + + const sheetText = childSheetText(view); + expect(sheetText).not.toContain('hidden chain of thought'); + expect(sheetText).toContain('Writing response'); + } + ); +}); + describe('SessionDetailContent goal visibility', () => { const pausedGoal: SessionGoal = { text: 'Ship p7 objective', status: 'paused' }; diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 8e19ea2f90..46c269ba4d 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -62,7 +62,7 @@ import { SessionPrBadge } from '@/components/agents/session-pr-badge'; import { selectSessionCostInputs } from '@/components/agents/session-list-helpers'; import { buildRemoteAttachmentParts } from '@/components/agents/mobile-session-manager-helpers'; import { isCancelQueuedUpgradeRequired } from '@/components/agents/mobile-session-manager'; -import { firstHumanText, isFilePart } from './part-types'; +import { firstHumanText, isFilePart, withoutReasoningParts } from './part-types'; import { buildRemoteAttachmentPartsWithRetryableFeedback, resolveSendAttachmentKind, @@ -140,6 +140,7 @@ import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; import { agentComposerDraftKey } from '@/lib/persist/drafts'; import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; import { useKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; +import { useHideThinkingPreference } from '@/lib/hooks/use-hide-thinking-preference'; import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; import { createRemoteModelOverride, @@ -331,6 +332,7 @@ export function SessionDetailContent({ const { saveModel: savePersistedModel } = usePersistedAgentModel(); const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId); const { defaultExpanded: reasoningDefaultExpanded } = useReasoningPreference(); + const { hideThinking, hasLoaded: hideThinkingLoaded } = useHideThinkingPreference(); const { keepScreenOn, hasLoaded: keepScreenOnLoaded } = useKeepScreenOnPreference(); const { models: gatewayModels, isLoading: gatewayModelsLoading } = useAvailableModels(organizationId); @@ -767,7 +769,29 @@ export function SessionDetailContent({ }); }, [messages, droppedQueuedIds, canceledQueuedMessages]); - const detailsMessage = visibleMessages.find(message => message.info.id === detailsMessageId); + // Visibility-only strip for the "Hide thinking details" option. Applied once + // here so the transcript, the message-details sheet, and the subagent views + // all lose their thinking rows/text from the same list. + // Until the persisted value resolves, reason optimistically that thinking is + // hidden: on a cold start with the option on, painting the rows first and + // stripping them when the disk read lands would flash the hidden thinking. + const hideReasoningRows = !hideThinkingLoaded || hideThinking; + const displayedMessages = useMemo( + () => (hideReasoningRows ? withoutReasoningParts(visibleMessages) : visibleMessages), + [visibleMessages, hideReasoningRows] + ); + + // Subagent transcript views resolve their rows through this callback, so the + // same option hides thinking inside an opened child session. + const getDisplayedChildMessages = useCallback( + (childSessionId: string) => { + const child = getChildMessages(childSessionId); + return hideReasoningRows ? withoutReasoningParts(child) : child; + }, + [getChildMessages, hideReasoningRows] + ); + + const detailsMessage = displayedMessages.find(message => message.info.id === detailsMessageId); const detailsDelivery = detailsMessageId === null ? undefined : pendingMessages.get(detailsMessageId); const detailsBusy = detailsMessageId !== null && cancelingQueuedIds.has(detailsMessageId); @@ -780,8 +804,8 @@ export function SessionDetailContent({ detailsBusy && isQueuedCancellationEligible(detailsMessage, detailsDelivery, false); const transcript = useMemo( - () => mergeSessionTranscript(visibleMessages, preparationAttempts, pendingMessages), - [visibleMessages, preparationAttempts, pendingMessages] + () => mergeSessionTranscript(displayedMessages, preparationAttempts, pendingMessages), + [displayedMessages, preparationAttempts, pendingMessages] ); // Render-phase state adjustment: hold queued ids across queue → dequeue @@ -1075,6 +1099,11 @@ export function SessionDetailContent({ message={item.message} isLastAssistantMessage={item.message.info.id === lastAssistantMessageId} isSessionStreaming={isStreaming} + // Raw child messages: the in-transcript task card derives its activity + // label from the child's latest part (child-session-card-state.ts:94-106), + // so feeding it the stripped list would turn a reasoning stream into a + // stale activity or "Waiting for activity" instead of "Thinking". + // The card renders no child rows, so nothing thinking-related leaks. getChildMessages={getChildMessages} modelOptions={modelOptions} defaultReasoningExpanded={reasoningDefaultExpanded} @@ -1631,7 +1660,8 @@ export function SessionDetailContent({ visible={childSessionSheet.visible} sessionId={childSessionSheet.sheet.sessionId} title={childSessionSheet.sheet.title} - getChildMessages={getChildMessages} + getChildMessages={getDisplayedChildMessages} + getIndicatorMessages={getChildMessages} hydrationState={getChildSessionHydrationState(childSessionSheet.sheet.sessionId)} sessionError={getChildSessionError(childSessionSheet.sheet.sessionId)} isStreaming={getChildSessionStreaming(messages, childSessionSheet.sheet.sessionId)} @@ -1718,6 +1748,11 @@ export function SessionDetailContent({ exiting={FadeOut.duration(150)} layout={LinearTransition.duration(150)} > + {/* Raw list on purpose: working-indicator.tsx:50-59 derives the + label from the last assistant part, and compute-status.ts:33-35 + maps a reasoning part to agentChat.partDetail.thinking, so the + spinner reads Thinking during a reasoning stream in both modes. + Feeding it displayedMessages would drop that label. */} {statusIndicator ? : null} diff --git a/apps/mobile/src/components/agents/session-detail-queue.test.ts b/apps/mobile/src/components/agents/session-detail-queue.test.ts index 7f3a3d0f99..4c1131534b 100644 --- a/apps/mobile/src/components/agents/session-detail-queue.test.ts +++ b/apps/mobile/src/components/agents/session-detail-queue.test.ts @@ -220,6 +220,12 @@ vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ useKeepScreenOnPreference: () => ({ hasLoaded: true, keepScreenOn: false }), })); +// The real hook reaches SecureStore via `@sentry/react-native`, which imports +// the native react-native entry; mock the preference the way the other +// preference hooks above are mocked so the suite stays DOM/native-free. +vi.mock('@/lib/hooks/use-hide-thinking-preference', () => ({ + useHideThinkingPreference: () => ({ hideThinking: false, hasLoaded: true }), +})); vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ useReasoningPreference: () => ({ defaultExpanded: false }), })); diff --git a/apps/mobile/src/components/agents/tool-part-renderer.test.ts b/apps/mobile/src/components/agents/tool-part-renderer.test.ts index 8464a66375..5bb173d2bd 100644 --- a/apps/mobile/src/components/agents/tool-part-renderer.test.ts +++ b/apps/mobile/src/components/agents/tool-part-renderer.test.ts @@ -244,8 +244,8 @@ const toolActivities: [string, Record, string][] = [ ].map(tool => [tool, {}, tool] satisfies [string, Record, string]), ]; const activityHistories: [string, StoredMessage[], string][] = [ - ['empty history', [], 'Waiting for activity'], - ['incomplete history', [makeStoredMessage()], 'Waiting for activity'], + ['empty history', [], 'Thinking'], + ['incomplete history', [makeStoredMessage()], 'Thinking'], [ 'delayed latest parts', [makeStoredMessage([textPart]), makeStoredMessage()], diff --git a/apps/mobile/src/components/agents/working-indicator.tsx b/apps/mobile/src/components/agents/working-indicator.tsx index a653f774f4..451e1c84ca 100644 --- a/apps/mobile/src/components/agents/working-indicator.tsx +++ b/apps/mobile/src/components/agents/working-indicator.tsx @@ -7,7 +7,7 @@ import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; import { formatDuration } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { computeStatus } from './compute-status'; +import { computeMessageStatus } from './compute-status'; type WorkingIndicatorProps = { messages: StoredMessage[]; @@ -44,15 +44,15 @@ export function WorkingIndicator({ messages, isStreaming }: Readonly= 0; i -= 1) { const msg = messages[i]; if (msg?.info.role === 'assistant' && msg.parts.length > 0) { - const lastPart = msg.parts.at(-1); - if (lastPart) { - statusText = computeStatus(lastPart); - } + statusText = computeMessageStatus(msg.parts); break; } } diff --git a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx index 356bede6bf..c68db8b664 100644 --- a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx +++ b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx @@ -108,6 +108,7 @@ vi.mock('@/components/ui/icons', () => ({ CheckCircle2: 'Icon', CornerDownLeft: 'Icon', Cpu: 'Icon', + EyeOff: 'Icon', Gauge: 'Icon', Globe: 'Icon', Info: 'Icon', diff --git a/apps/mobile/src/components/general-settings-screen.mounted.test.tsx b/apps/mobile/src/components/general-settings-screen.mounted.test.tsx index 6bef88e9ae..dcd63b7776 100644 --- a/apps/mobile/src/components/general-settings-screen.mounted.test.tsx +++ b/apps/mobile/src/components/general-settings-screen.mounted.test.tsx @@ -53,6 +53,7 @@ vi.mock('@/components/ui/icons', () => ({ Brain: 'Brain', CornerDownLeft: 'CornerDownLeft', Cpu: 'Cpu', + EyeOff: 'EyeOff', Globe: 'Globe', MessageSquare: 'MessageSquare', Mic: 'Mic', @@ -62,6 +63,13 @@ vi.mock('@/components/ui/icons', () => ({ vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-hide-thinking-preference', () => ({ + useHideThinkingPreference: () => ({ + hideThinking: false, + hasLoaded: true, + setHideThinking: vi.fn(), + }), +})); vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ useKeepScreenOnPreference: () => ({ keepScreenOn: false, @@ -137,7 +145,7 @@ afterEach(() => { }); describe('GeneralSettingsScreen', () => { - it('renders the five moved settings with their exact titles and subtitles', async () => { + it('renders the six settings with their exact titles and subtitles', async () => { const renderer = await mountGeneral(); const rendered = texts(renderer); @@ -145,6 +153,8 @@ describe('GeneralSettingsScreen', () => { expect(rendered).toContain('Unlock at launch and after five minutes in the background.'); expect(rendered).toContain('Auto expand thinking'); expect(rendered).toContain("Show the agent's thinking expanded when it finishes."); + expect(rendered).toContain('Hide thinking details'); + expect(rendered).toContain("Don't show the agent's thinking on the session page."); expect(rendered).toContain('Keep screen on while on session page'); expect(rendered).toContain('Hold the screen awake while the session is working.'); expect(rendered).toContain('Add app attribution to PR reviews'); @@ -167,4 +177,18 @@ describe('GeneralSettingsScreen', () => { expect(biometricSwitch?.props).toMatchObject({ value: false, disabled: false }); expect(native.authenticateAsync).not.toHaveBeenCalled(); }); + + it('mounts the hide-thinking switch off and enabled once the preference load settles', async () => { + const renderer = await mountGeneral(); + + const switches = renderer.renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'Switch' + ); + const hideThinkingSwitch = switches.find( + sw => sw.props.accessibilityLabel === 'Hide thinking details' + ); + + expect(hideThinkingSwitch).toBeDefined(); + expect(hideThinkingSwitch?.props).toMatchObject({ value: false, disabled: false }); + }); }); diff --git a/apps/mobile/src/components/general-settings-screen.tsx b/apps/mobile/src/components/general-settings-screen.tsx index 91934d60a8..05c314d927 100644 --- a/apps/mobile/src/components/general-settings-screen.tsx +++ b/apps/mobile/src/components/general-settings-screen.tsx @@ -1,4 +1,11 @@ -import { Brain, CornerDownLeft, MessageSquare, Shield, Smartphone } from '@/components/ui/icons'; +import { + Brain, + CornerDownLeft, + EyeOff, + MessageSquare, + Shield, + Smartphone, +} from '@/components/ui/icons'; import { useTranslation } from 'react-i18next'; import { View } from 'react-native'; @@ -7,6 +14,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { TabScreenScrollView } from '@/components/tab-screen'; import { PreferenceRow } from '@/components/ui/preference-row'; import { useAppUnlock } from '@/lib/app-unlock-context'; +import { useHideThinkingPreference } from '@/lib/hooks/use-hide-thinking-preference'; import { useKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; import { usePrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-preference'; import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; @@ -25,6 +33,11 @@ export function GeneralSettingsScreen() { hasLoaded: reasoningLoaded, setDefaultExpanded, } = useReasoningPreference(); + const { + hideThinking, + hasLoaded: hideThinkingLoaded, + setHideThinking, + } = useHideThinkingPreference(); const { keepScreenOn, hasLoaded: keepScreenOnLoaded, @@ -67,6 +80,14 @@ export function GeneralSettingsScreen() { disabled={!reasoningLoaded} onValueChange={setDefaultExpanded} /> + ({ clearRunOnDestinationPreference: vi.fn(), })); -const { clearKeepScreenOnPreference, clearReasoningPreference, clearPrReviewFooterPreference } = - vi.hoisted(() => ({ - clearKeepScreenOnPreference: vi.fn(), - clearReasoningPreference: vi.fn(), - clearPrReviewFooterPreference: vi.fn(), - })); +const { + clearHideThinkingPreference, + clearKeepScreenOnPreference, + clearReasoningPreference, + clearPrReviewFooterPreference, +} = vi.hoisted(() => ({ + clearHideThinkingPreference: vi.fn(), + clearKeepScreenOnPreference: vi.fn(), + clearReasoningPreference: vi.fn(), + clearPrReviewFooterPreference: vi.fn(), +})); vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ clearKeepScreenOnPreference })); vi.mock('@/lib/hooks/use-live-activity-preference', () => ({ clearLiveActivityPreference: vi.fn(), @@ -241,6 +246,11 @@ vi.mock('@/lib/hooks/use-live-activity-preference', () => ({ vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ clearReasoningPreference })); +// Like use-trusted-hosts below: the real module pulls secure-store-preference +// -> sonner-native -> react-native (Flow `import typeof`), which crashes the +// node test environment. Mock it to keep sign-out teardown under test. +vi.mock('@/lib/hooks/use-hide-thinking-preference', () => ({ clearHideThinkingPreference })); + // These imported session-clear modules pull in native bindings that crash the // node test environment: use-trusted-hosts -> secure-store-preference -> // sonner-native -> react-native (Flow `import typeof`), and the cache/file @@ -632,6 +642,7 @@ describe('sign-out teardown ordering', () => { expect(clearKeepScreenOnPreference).toHaveBeenCalled(); expect(clearReasoningPreference).toHaveBeenCalled(); + expect(clearHideThinkingPreference).toHaveBeenCalled(); expect(clearPrReviewFooterPreference).toHaveBeenCalled(); const { clearRunOnDestinationPreference } = await import('@/lib/hooks/use-persisted-run-on-destination'); diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 59f92f2b2b..93e05626ca 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -50,6 +50,7 @@ import { clearKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-pref import { clearLiveActivityPreference } from '@/lib/hooks/use-live-activity-preference'; import { clearPrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-preference'; import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; +import { clearHideThinkingPreference } from '@/lib/hooks/use-hide-thinking-preference'; import { clearSessionScopedState } from '@/lib/auth/session-scoped-state'; import { clearKiloClawOwned, gateKiloClawOwned } from '@/lib/kiloclaw-tab-ownership'; import { clearLastActiveInstance } from '@/lib/last-active-instance'; @@ -479,6 +480,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { clearAgentModelPreference(); clearRunOnDestinationPreference(); clearReasoningPreference(); + clearHideThinkingPreference(); clearKeepScreenOnPreference(); clearLiveActivityPreference(); clearSessionScopedState(); diff --git a/apps/mobile/src/lib/auth/credentials.test.ts b/apps/mobile/src/lib/auth/credentials.test.ts index f50a8503de..1d480165c1 100644 --- a/apps/mobile/src/lib/auth/credentials.test.ts +++ b/apps/mobile/src/lib/auth/credentials.test.ts @@ -71,6 +71,9 @@ vi.mock('@/lib/hooks/use-pr-review-footer-preference', () => ({ clearPrReviewFooterPreference: vi.fn(), })); vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ clearReasoningPreference: vi.fn() })); +vi.mock('@/lib/hooks/use-hide-thinking-preference', () => ({ + clearHideThinkingPreference: vi.fn(), +})); vi.mock('@/lib/kiloclaw-tab-ownership', () => ({ gateKiloClawOwned: vi.fn(), clearKiloClawOwned: vi.fn(), diff --git a/apps/mobile/src/lib/hooks/use-hide-thinking-preference.test.ts b/apps/mobile/src/lib/hooks/use-hide-thinking-preference.test.ts new file mode 100644 index 0000000000..5820b6b09d --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-hide-thinking-preference.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { getItemAsync, setItemAsync, deleteItemAsync } = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), +})); +vi.mock('expo-secure-store', () => ({ getItemAsync, setItemAsync, deleteItemAsync })); + +const { captureException } = vi.hoisted(() => ({ captureException: vi.fn() })); +vi.mock('@sentry/react-native', () => ({ captureException })); + +const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() })); +vi.mock('sonner-native', () => ({ toast: { error: toastError } })); + +// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule +function flushMicrotasks(): Promise { + return new Promise(resolve => { + setImmediate(resolve); + }); +} + +// eslint-disable-next-line no-empty-function -- listener body is irrelevant, only subscribe()'s side effect (starting the load) is under test +function noopListener(): void {} + +// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule +function makeStore() { + // Re-import lazily so the mock wiring above is in effect. + return import('./secure-store-preference').then(({ createSecureStorePreference }) => + createSecureStorePreference({ + key: 'agent-hide-thinking-details', + defaultValue: false, + parse: raw => raw === 'true', + serialize: value => (value ? 'true' : 'false'), + }) + ); +} + +describe('parseHideThinking', () => { + it('defaults to off for a missing value (fresh install and unreadable read)', async () => { + const { parseHideThinking } = await import('./use-hide-thinking-preference'); + expect(parseHideThinking(null)).toBe(false); + }); + + it("reads 'true' as on — the only value that turns the preference on", async () => { + const { parseHideThinking } = await import('./use-hide-thinking-preference'); + expect(parseHideThinking('true')).toBe(true); + }); + + it("reads 'false' as off", async () => { + const { parseHideThinking } = await import('./use-hide-thinking-preference'); + expect(parseHideThinking('false')).toBe(false); + }); + + it('treats any other stored string as off', async () => { + const { parseHideThinking } = await import('./use-hide-thinking-preference'); + expect(parseHideThinking('')).toBe(false); + expect(parseHideThinking('nonsense')).toBe(false); + }); +}); + +describe('hide-thinking store', () => { + beforeEach(() => { + getItemAsync.mockReset(); + setItemAsync.mockReset(); + deleteItemAsync.mockReset(); + captureException.mockReset(); + toastError.mockReset(); + }); + + it('defaults to off when SecureStore returns null', async () => { + getItemAsync.mockResolvedValue(null); + const store = await makeStore(); + + const unsubscribe = store.subscribe(noopListener); + await flushMicrotasks(); + + expect(store.get()).toBe(false); + expect(store.getHasLoaded()).toBe(true); + unsubscribe(); + }); + + it("turns on only for the stored string 'true'", async () => { + getItemAsync.mockResolvedValue('true'); + const store = await makeStore(); + + const unsubscribe = store.subscribe(noopListener); + await flushMicrotasks(); + + expect(store.get()).toBe(true); + expect(store.getHasLoaded()).toBe(true); + unsubscribe(); + }); + + it('persists a set value and clears back to the default on sign-out', async () => { + getItemAsync.mockResolvedValue(null); + const store = await makeStore(); + + store.set(true); + await flushMicrotasks(); + expect(setItemAsync).toHaveBeenCalledWith('agent-hide-thinking-details', 'true'); + + store.set(false); + await flushMicrotasks(); + expect(setItemAsync).toHaveBeenCalledWith('agent-hide-thinking-details', 'false'); + + store.clear(); + await flushMicrotasks(); + expect(deleteItemAsync).toHaveBeenCalledWith('agent-hide-thinking-details'); + expect(store.get()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-hide-thinking-preference.ts b/apps/mobile/src/lib/hooks/use-hide-thinking-preference.ts new file mode 100644 index 0000000000..ca7c3b713f --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-hide-thinking-preference.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from 'react'; + +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { HIDE_THINKING_KEY } from '@/lib/storage-keys'; + +/** + * Default-off preference: only the exact stored string 'true' turns it on, so a + * missing or unreadable value keeps the thinking rows visible. + */ +export function parseHideThinking(raw: string | null): boolean { + return raw === 'true'; +} + +const store = createSecureStorePreference({ + key: HIDE_THINKING_KEY, + defaultValue: false, + parse: parseHideThinking, + serialize: value => (value ? 'true' : 'false'), +}); + +export function clearHideThinkingPreference() { + store.clear(); +} + +function setHideThinking(value: boolean) { + store.set(value); +} + +export function useHideThinkingPreference() { + const hideThinking = useSyncExternalStore(store.subscribe, store.get); + const hasLoaded = useSyncExternalStore(store.subscribe, store.getHasLoaded); + return { hideThinking, hasLoaded, setHideThinking }; +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index 62ab34cb40..83d9faf02f 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -19,6 +19,7 @@ export const LAST_RUN_ON_DESTINATION_KEY = 'last-run-on-destination'; export const CONSENT_USER_KEY_PREFIX = 'consent-accepted-'; export const AGENT_MODEL_PREFERENCE_KEY = 'agent-model-preference'; export const REASONING_DEFAULT_EXPANDED_KEY = 'agent-reasoning-default-expanded'; +export const HIDE_THINKING_KEY = 'agent-hide-thinking-details'; export const REVIEW_REQUESTED_AT_KEY = 'store-review-requested-at'; /** One-time gate for the neutral post-success feedback prompt. */ export const FEEDBACK_LAST_ASKED_AT_KEY = 'feedback-last-asked-at';