diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/preferences.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/preferences.tsx new file mode 100644 index 0000000000..557a2a72a3 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/preferences.tsx @@ -0,0 +1,5 @@ +import { PreferencesScreen } from '@/components/preferences-screen'; + +export default function PreferencesRoute() { + return ; +} diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index 92f41695a9..999d32c97c 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -1,12 +1,8 @@ -import { useState } from 'react'; -import { Pressable, View } from 'react-native'; -import { Settings2 } from 'lucide-react-native'; +import { View } from 'react-native'; -import { ReasoningSettingsModal } from '@/components/agents/reasoning-settings-modal'; import { type AgentMode, ModeSelector } from '@/components/agents/mode-selector'; import { ModelSelector } from '@/components/agents/model-selector'; import { type ModelOption } from '@/lib/hooks/use-available-models'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; type ChatToolbarOrder = 'mode-first' | 'model-first'; @@ -22,7 +18,6 @@ type ChatToolbarProps = { isLoadingModels?: boolean; order?: ChatToolbarOrder; className?: string; - showReasoningSettings?: boolean; }; export function ChatToolbar({ @@ -36,10 +31,7 @@ export function ChatToolbar({ isLoadingModels = false, order = 'mode-first', className, - showReasoningSettings = true, }: Readonly) { - const colors = useThemeColors(); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); const modeSelector = ; const modelSelector = ( {order === 'model-first' ? modelSelector : modeSelector} {order === 'model-first' ? modeSelector : modelSelector} - {showReasoningSettings ? ( - <> - { - if (!disabled) { - setIsSettingsOpen(true); - } - }} - disabled={disabled} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - className="ml-auto h-8 w-8 items-center justify-center rounded-full active:opacity-70" - accessibilityRole="button" - accessibilityLabel="Reasoning settings" - accessibilityState={{ disabled }} - > - - - { - setIsSettingsOpen(false); - }} - /> - - ) : null} ); } diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts index 63174df3eb..9f92d55735 100644 --- a/apps/mobile/src/components/agents/message-bubble.test.ts +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -1,7 +1,6 @@ -/* eslint-disable max-lines -- Queued-badge, delivery, a11y, and time-label seams share the direct-invocation MessageBubble harness. */ +/* eslint-disable max-lines -- Queued-badge, delivery, and a11y seams share the direct-invocation MessageBubble harness. */ import { describe, expect, it, vi } from 'vitest'; -import { formatTranscriptTimeLabel } from './message-time-label'; import { assistantMessage, findElementByType, @@ -151,78 +150,6 @@ describe('MessageBubble failed delivery state', () => { }); }); -describe('MessageBubble time label', () => { - it('renders a same-day time label matching the formatter evaluated in the test', async () => { - const created = Date.now(); - const message = userMessage('m-time-same-day'); - message.info.time = { created }; - const tree = await renderBubble(message); - const expected = formatTranscriptTimeLabel(created, Date.now()); - expect(expected).not.toBeNull(); - expect(findText(tree, t => t === expected)).toBe(true); - }); - - it('renders the user time label in the meta row after the queued badge slot', async () => { - const tree = await renderBubble(userMessage('m-time-user'), { status: 'queued' }); - const metaRow = findElementByType( - tree, - 'View', - p => - typeof p.className === 'string' && - p.className.includes('flex-row items-center gap-2 self-end pr-1') - ); - expect(metaRow).not.toBeNull(); - if (!metaRow) { - throw new Error('expected meta row'); - } - const children = Array.isArray(metaRow.props.children) - ? metaRow.props.children - : [metaRow.props.children]; - expect(children.length).toBe(2); - const badge = children[0] as { props?: Record }; - const badgeClass = typeof badge.props?.className === 'string' ? badge.props.className : null; - expect(badgeClass).not.toBeNull(); - expect(badgeClass?.includes(BADGE_CLASS)).toBe(true); - const label = children[1] as { props?: Record }; - const labelClass = typeof label.props?.className === 'string' ? label.props.className : null; - expect(labelClass).not.toBeNull(); - expect(labelClass?.includes('tabular-nums')).toBe(true); - expect(typeof label.props?.children).toBe('string'); - }); - - it('renders the assistant time label after the parts view', async () => { - const tree = await renderBubble(assistantMessage('m-time-asst')); - const pressable = findElementByType(tree, 'Pressable'); - expect(pressable).not.toBeNull(); - if (!pressable) { - throw new Error('expected pressable'); - } - const children = Array.isArray(pressable.props.children) - ? pressable.props.children - : [pressable.props.children]; - const partsIndex = children.findIndex(child => - subtreeContains(child, p => typeof p.className === 'string' && p.className.includes('gap-2')) - ); - const labelIndex = children.findIndex(child => subtreeContainsTimeLabel(child)); - expect(partsIndex).toBeGreaterThanOrEqual(0); - expect(labelIndex).toBeGreaterThan(partsIndex); - }); - - it('does not render a time label when time.created is absent', async () => { - const message = userMessage('m-time-absent'); - (message.info.time as { created?: number }).created = undefined; - const tree = await renderBubble(message); - expect(findTimeLabel(tree)).toBeNull(); - }); - - it('does not render a time label when time.created is invalid', async () => { - const message = assistantMessage('m-time-invalid'); - message.info.time = { created: Number.NaN }; - const tree = await renderBubble(message); - expect(findTimeLabel(tree)).toBeNull(); - }); -}); - describe('MessageBubble regressions', () => { it('holds badge slot when queued and holdQueuedSlot is set after dequeue', async () => { const message = userMessage('m7'); @@ -351,54 +278,3 @@ function findProvider( } return null; } - -function subtreeContains( - node: unknown, - predicate: (props: Record) => boolean -): boolean { - if (node == null || typeof node !== 'object') { - return false; - } - const element = node as { type?: unknown; props?: Record }; - if (predicate(element.props ?? {})) { - return true; - } - const children = element.props?.children; - if (Array.isArray(children)) { - return children.some(child => subtreeContains(child, predicate)); - } - if (children && typeof children === 'object') { - return subtreeContains(children, predicate); - } - return false; -} - -function subtreeContainsTimeLabel(node: unknown): boolean { - return subtreeContains( - node, - p => typeof p.className === 'string' && p.className.includes('tabular-nums') - ); -} - -function findTimeLabel(node: unknown): { props: Record } | null { - if (node == null || typeof node !== 'object') { - return null; - } - const element = node as { type?: unknown; props?: Record }; - const props = element.props ?? {}; - if (typeof props.className === 'string' && props.className.includes('tabular-nums')) { - return { props }; - } - const children = element.props?.children; - if (Array.isArray(children)) { - for (const child of children) { - const hit = findTimeLabel(child); - if (hit) { - return hit; - } - } - } else if (children && typeof children === 'object') { - return findTimeLabel(children); - } - return null; -} diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index c923f42d69..1b27fbf7ec 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -11,7 +11,6 @@ import { ChatMarkdownText } from './chat-markdown-text'; import { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; import { buildAgentMessageBubbleAccessibilityProps } from './message-bubble-a11y'; -import { formatTranscriptTimeLabel } from './message-time-label'; import { PartRenderer } from './part-renderer'; import { isFilePart, isTextPart } from './part-types'; import { useMessageCopy } from './use-message-copy'; @@ -79,12 +78,6 @@ export function MessageBubble({ ); } - // Subtle time label, computed once per render. `Date.now()` at render time - // only: no timer and no day-boundary watcher, so a message mounted across - // midnight keeps its label until the next render (stream tick, list recycle, - // navigation). - const timeLabel = formatTranscriptTimeLabel(message.info.time.created, Date.now()); - if (isUser) { // Composer, queued-message synthesis, and slash commands emit exactly one // human-authored text part, so the separator separates it from synthesized @@ -111,29 +104,24 @@ export function MessageBubble({ ))} - {hasBadgeSlot || timeLabel ? ( + {hasBadgeSlot ? ( - {hasBadgeSlot ? ( - - - Queued - - ) : null} - {timeLabel ? ( - {timeLabel} - ) : null} + + + Queued + ) : null} @@ -173,9 +161,6 @@ export function MessageBubble({ ))} - {timeLabel ? ( - {timeLabel} - ) : null} {a11y.accessibilityActions.length > 0 ? ( { expect(formatTranscriptTimeLabel(created, now)).toBe(DATE_TIME.format(new Date(created))); }); }); + +describe('isSameLocalDay', () => { + it('returns true for two instants anywhere on the same local calendar day', () => { + const dayStart = new Date(2026, 0, 1, 0, 0, 1).getTime(); + const dayEnd = new Date(2026, 0, 1, 23, 59, 59).getTime(); + expect(isSameLocalDay(dayStart, dayEnd)).toBe(true); + }); + + it('returns false for two instants one second across local midnight', () => { + const beforeMidnight = new Date(2026, 0, 1, 23, 59, 59).getTime(); + const afterMidnight = new Date(2026, 0, 2, 0, 0, 0).getTime(); + expect(isSameLocalDay(beforeMidnight, afterMidnight)).toBe(false); + }); +}); + +describe('isValidTranscriptTime', () => { + it('rejects absent, non-positive, non-finite, and out-of-range epochs', () => { + expect(isValidTranscriptTime(undefined)).toBe(false); + expect(isValidTranscriptTime(null)).toBe(false); + expect(isValidTranscriptTime(0)).toBe(false); + expect(isValidTranscriptTime(-1)).toBe(false); + expect(isValidTranscriptTime(Number.NaN)).toBe(false); + expect(isValidTranscriptTime(Number.MAX_VALUE)).toBe(false); + }); + + it('accepts a current epoch', () => { + expect(isValidTranscriptTime(Date.now())).toBe(true); + }); +}); + +describe('formatTranscriptMarkerLabel', () => { + it('always shows the date for a day-change marker even when the day is today', () => { + const created = new Date(2026, 7, 5, 14, 32).getTime(); + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptMarkerLabel(created, now, true)).toBe( + DATE_TIME.format(new Date(created)) + ); + }); + + it('keeps the message-label rule when the day did not change', () => { + const created = new Date(2026, 7, 5, 14, 32).getTime(); + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptMarkerLabel(created, now, false)).toBe( + formatTranscriptTimeLabel(created, now) + ); + }); + + it('returns null for an out-of-range epoch with either flag', () => { + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptMarkerLabel(Number.MAX_VALUE, now, true)).toBeNull(); + expect(formatTranscriptMarkerLabel(Number.MAX_VALUE, now, false)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/agents/message-time-label.ts b/apps/mobile/src/components/agents/message-time-label.ts index 4aff5cf30b..7a72358158 100644 --- a/apps/mobile/src/components/agents/message-time-label.ts +++ b/apps/mobile/src/components/agents/message-time-label.ts @@ -5,6 +5,33 @@ const DATE_TIME = new Intl.DateTimeFormat(undefined, { timeStyle: 'short', }); +/** The Date for a transcript timestamp, or null when it is absent or unusable. */ +function toTranscriptDate(created: number | undefined | null): Date | null { + if (created === undefined || created === null || !Number.isFinite(created) || created <= 0) { + return null; + } + const date = new Date(created); + // A finite but out-of-range epoch (e.g. Number.MAX_VALUE) yields an Invalid Date, + // and Intl.DateTimeFormat.format throws on one. + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Whether a timestamp can be rendered at all. The builder and the marker share this rule. */ +export function isValidTranscriptTime(created: number | undefined | null): boolean { + return toTranscriptDate(created) !== null; +} + +/** Whether two epoch-ms instants fall on the same local calendar day. */ +export function isSameLocalDay(aMs: number, bMs: number): boolean { + const a = new Date(aMs); + const b = new Date(bMs); + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + /** * Format an epoch-ms created timestamp for the message time label. * Same local day as `now` → time only; any other local day → date and time. @@ -15,17 +42,32 @@ export function formatTranscriptTimeLabel( created: number | undefined | null, now: number ): string | null { - if (created === undefined || created === null || !Number.isFinite(created) || created <= 0) { + const date = toTranscriptDate(created); + if (date === null) { return null; } - const createdDate = new Date(created); - if (Number.isNaN(createdDate.getTime())) { + return isSameLocalDay(date.getTime(), now) ? TIME_ONLY.format(date) : DATE_TIME.format(date); +} + +/** + * Label for a transcript time marker. + * + * `dayChanged` is true when the marker opens a different local calendar day than the + * previous marker's run. Such a marker always carries the date, even when it falls + * today: the date is the information the marker exists to deliver. Every other marker + * keeps the message-label rule — today shows the time, an older day shows date and time. + */ +export function formatTranscriptMarkerLabel( + created: number | undefined | null, + now: number, + dayChanged: boolean +): string | null { + const date = toTranscriptDate(created); + if (date === null) { return null; } - const nowDate = new Date(now); - const sameDay = - createdDate.getFullYear() === nowDate.getFullYear() && - createdDate.getMonth() === nowDate.getMonth() && - createdDate.getDate() === nowDate.getDate(); - return sameDay ? TIME_ONLY.format(createdDate) : DATE_TIME.format(createdDate); + if (dayChanged) { + return DATE_TIME.format(date); + } + return formatTranscriptTimeLabel(created, now); } diff --git a/apps/mobile/src/components/agents/message-visibility.test.ts b/apps/mobile/src/components/agents/message-visibility.test.ts new file mode 100644 index 0000000000..3d9ccc8578 --- /dev/null +++ b/apps/mobile/src/components/agents/message-visibility.test.ts @@ -0,0 +1,159 @@ +import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { messageRendersContent, partRendersContent } from './message-visibility'; + +function textPart(overrides: Partial> = {}): Part { + return { + id: 'p1', + sessionID: 's1', + messageID: 'm1', + type: 'text', + text: 'hello', + ...overrides, + }; +} + +function reasoningPart(text: string): Part { + return { + id: 'p2', + sessionID: 's1', + messageID: 'm1', + type: 'reasoning', + text, + time: { start: 1, end: 2 }, + }; +} + +function toolPart(tool: string): Part { + return { + id: 'p3', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool, + state: { + status: 'completed', + input: {}, + output: '', + title: 't', + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; +} + +function assistantMessage(parts: Part[]): StoredMessage { + return { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'm0', + modelID: 'model', + 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, + }; +} + +describe('partRendersContent', () => { + it('returns false for a step-start part', () => { + const part: Part = { id: 'p', sessionID: 's1', messageID: 'm1', type: 'step-start' }; + expect(partRendersContent(part)).toBe(false); + }); + + it('returns false for a synthetic Initializing snapshot text part', () => { + expect( + partRendersContent(textPart({ synthetic: true, text: '⠋ Initializing snapshot…' })) + ).toBe(false); + }); + + it('returns false for a reasoning part with whitespace-only text', () => { + expect(partRendersContent(reasoningPart(' \n '))).toBe(false); + }); + + it('returns false for a plan_exit tool part', () => { + expect(partRendersContent(toolPart('plan_exit'))).toBe(false); + }); + + it('returns false for an empty text part', () => { + expect(partRendersContent(textPart({ text: '' }))).toBe(false); + }); + + it('returns true for a bash tool part', () => { + expect(partRendersContent(toolPart('bash'))).toBe(true); + }); + + it('returns true for a compaction part', () => { + const part: Part = { + id: 'p', + sessionID: 's1', + messageID: 'm1', + type: 'compaction', + auto: true, + }; + expect(partRendersContent(part)).toBe(true); + }); + + it('returns true for a file part', () => { + const part: Part = { + id: 'p', + sessionID: 's1', + messageID: 'm1', + type: 'file', + mime: 'text/plain', + url: 'file:///a.txt', + }; + expect(partRendersContent(part)).toBe(true); + }); +}); + +describe('messageRendersContent', () => { + it('returns false for an assistant message with zero parts', () => { + expect(messageRendersContent(assistantMessage([]))).toBe(false); + }); + + it('returns false for an assistant message whose only part is step-start', () => { + const part: Part = { id: 'p', sessionID: 's1', messageID: 'm1', type: 'step-start' }; + expect(messageRendersContent(assistantMessage([part]))).toBe(false); + }); + + it('returns true when a retry part is followed by a non-empty text part', () => { + const retry: Part = { + id: 'p-retry', + sessionID: 's1', + messageID: 'm1', + type: 'retry', + attempt: 1, + error: { + name: 'APIError', + data: { message: 'nope', isRetryable: true }, + }, + time: { created: 1 }, + }; + expect(messageRendersContent(assistantMessage([retry, textPart()]))).toBe(true); + }); + + it('returns true for a user message with zero parts', () => { + const user: StoredMessage = { + info: { + id: 'm1', + sessionID: 's1', + role: 'user', + time: { created: 1 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'model' }, + }, + parts: [], + }; + expect(messageRendersContent(user)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/agents/message-visibility.ts b/apps/mobile/src/components/agents/message-visibility.ts new file mode 100644 index 0000000000..7f3a918b17 --- /dev/null +++ b/apps/mobile/src/components/agents/message-visibility.ts @@ -0,0 +1,44 @@ +import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; + +import { + isCompactionPart, + isFilePart, + isReasoningPart, + isSnapshotProgressPart, + isTextPart, + isToolPart, + shouldRenderReasoningPart, +} from './part-types'; + +/** + * Whether `PartRenderer` renders visible content for this part. + * + * `PartRenderer` is the only consumer of the null cases, so this predicate is the + * single source for "renders nothing": the renderer gates on it, and the transcript + * builder drops a message that has no content and never puts a time marker above it. + */ +export function partRendersContent(part: Part): boolean { + if (isTextPart(part)) { + // Snapshot-init progress shows only in the fixed WorkingIndicator row, and + // TextPartRenderer renders nothing for empty text. + return !isSnapshotProgressPart(part) && part.text !== ''; + } + if (isToolPart(part)) { + // ToolPartRenderer renders nothing for the plan-mode transition tools. + return part.tool !== 'plan_enter' && part.tool !== 'plan_exit'; + } + if (isReasoningPart(part)) { + // The second argument is unused by shouldRenderReasoningPart; visibility is a + // property of the part, not of the stream. + return shouldRenderReasoningPart(part, false); + } + return isFilePart(part) || isCompactionPart(part); +} + +/** + * Whether the message renders anything in the transcript. A user message always + * renders its bubble; an assistant message renders only what its parts render. + */ +export function messageRendersContent(message: StoredMessage): boolean { + return message.info.role === 'user' || message.parts.some(partRendersContent); +} diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index d42ce0924e..0ef8b26878 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -3,15 +3,14 @@ import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; import { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; import { MessageErrorBoundary } from './message-error-boundary'; +import { partRendersContent } from './message-visibility'; import { isCompactionPart, isFilePart, isPartStreaming, isReasoningPart, - isSnapshotProgressPart, isTextPart, isToolPart, - shouldRenderReasoningPart, } from './part-types'; import { ReasoningPartRenderer } from './reasoning-part-renderer'; import { TextPartRenderer } from './text-part-renderer'; @@ -33,12 +32,10 @@ export function PartRenderer({ defaultReasoningExpanded, onOpenChildSession, }: Readonly) { + if (!partRendersContent(part)) { + return null; + } if (isTextPart(part)) { - // Snapshot-init progress is shown only in the fixed WorkingIndicator row. - // Hide unconditionally so a persisted part never lingers in the transcript. - if (isSnapshotProgressPart(part)) { - return null; - } return ( @@ -58,9 +55,6 @@ export function PartRenderer({ ); } if (isReasoningPart(part)) { - if (!shouldRenderReasoningPart(part, isStreaming ?? false)) { - return null; - } return ( void; -}; - -export function ReasoningSettingsModal({ - visible, - onClose, -}: Readonly) { - const colors = useThemeColors(); - const { defaultExpanded, hasLoaded, setDefaultExpanded } = useReasoningPreference(); - - return ( - - - - { - event.stopPropagation(); - }} - > - Reasoning - {/* The native Switch below is the single accessible control (it already - exposes an accessibility switch role/state on its own); this row is - a visual hit-target only, so a screen reader doesn't see two nested - switches. */} - { - if (!hasLoaded) { - return; - } - setDefaultExpanded(!defaultExpanded); - }} - disabled={!hasLoaded} - accessible={false} - className="flex-row items-center justify-between gap-3 rounded-lg p-2 active:opacity-70 disabled:opacity-50" - hitSlop={ - Platform.OS === 'android' ? { top: 12, bottom: 12, left: 12, right: 12 } : undefined - } - > - - - Expand reasoning by default - - - Show the assistant's reasoning expanded when it finishes. - - - - - - - - ); -} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 45e3ec9f7b..abc470977d 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -48,6 +48,8 @@ import { shouldShowFooterWorkingIndicator, shouldShowSessionFooterRow, } from '@/components/agents/session-working-state'; +import { shouldKeepSessionAwake } from '@/components/agents/session-keep-awake'; +import { TranscriptTimeMarker } from '@/components/agents/transcript-time-marker'; import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { @@ -94,6 +96,7 @@ import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; +import { useKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; import { createRemoteModelOverride, @@ -221,6 +224,7 @@ export function SessionDetailContent({ const { saveModel: savePersistedModel } = usePersistedAgentModel(); const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId); const { defaultExpanded: reasoningDefaultExpanded } = useReasoningPreference(); + const { keepScreenOn, hasLoaded: keepScreenOnLoaded } = useKeepScreenOnPreference(); const { models: gatewayModels, isLoading: gatewayModelsLoading } = useAvailableModels(organizationId); const sessionModels = useSessionModelOptions({ @@ -432,6 +436,9 @@ export function SessionDetailContent({ if (item.type === 'preparation') { return ; } + if (item.type === 'time') { + return ; + } // Look up delivery state by message id. The map is keyed by user-message // id and may briefly contain an entry before the bubble has rendered // (ServiceEvents can be applied while chat events are still buffered), @@ -776,10 +783,14 @@ export function SessionDetailContent({ }, [continueSession, fetchedData?.gitUrl, currentMode, currentModel, currentVariant]); const isFocused = useIsFocused(); - // Focus bounds the awake window to the visible working UI; a backgrounded - // or covered screen must not hold the OS idle timer. - const keepScreenAwake = - isFocused && agentStatus.type !== 'disconnected' && (isStreaming || pendingMessages.size > 0); + const keepScreenAwake = shouldKeepSessionAwake({ + keepScreenOn, + preferenceLoaded: keepScreenOnLoaded, + isFocused, + isDisconnected: agentStatus.type === 'disconnected', + isStreaming, + pendingMessageCount: pendingMessages.size, + }); return ( diff --git a/apps/mobile/src/components/agents/session-keep-awake.test.ts b/apps/mobile/src/components/agents/session-keep-awake.test.ts new file mode 100644 index 0000000000..b1327da0c1 --- /dev/null +++ b/apps/mobile/src/components/agents/session-keep-awake.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldKeepSessionAwake } from '@/components/agents/session-keep-awake'; + +function awakeState( + overrides: Readonly[0]>> = {} +): Parameters[0] { + return { + keepScreenOn: true, + preferenceLoaded: true, + isFocused: true, + isDisconnected: false, + isStreaming: false, + pendingMessageCount: 0, + ...overrides, + }; +} + +describe('shouldKeepSessionAwake', () => { + it('keeps the screen awake while on, focused, connected, and streaming', () => { + expect(shouldKeepSessionAwake(awakeState({ isStreaming: true }))).toBe(true); + }); + + it('never keeps the screen awake when the preference is off, even while streaming', () => { + expect(shouldKeepSessionAwake(awakeState({ keepScreenOn: false, isStreaming: true }))).toBe( + false + ); + }); + + it('never keeps the screen awake when off with a pending message', () => { + expect( + shouldKeepSessionAwake(awakeState({ keepScreenOn: false, pendingMessageCount: 1 })) + ).toBe(false); + }); + + it('lets an idle session sleep (not streaming, no pending messages)', () => { + expect(shouldKeepSessionAwake(awakeState())).toBe(false); + }); + + it('never keeps the screen awake when the screen is not focused', () => { + expect(shouldKeepSessionAwake(awakeState({ isFocused: false, isStreaming: true }))).toBe(false); + }); + + it('never keeps the screen awake while disconnected', () => { + expect(shouldKeepSessionAwake(awakeState({ isDisconnected: true, isStreaming: true }))).toBe( + false + ); + }); + + it('keeps the screen awake for a pending message without streaming', () => { + expect(shouldKeepSessionAwake(awakeState({ pendingMessageCount: 1 }))).toBe(true); + }); + + it('treats an unloaded preference as off, so the read window never holds the lock', () => { + expect(shouldKeepSessionAwake(awakeState({ preferenceLoaded: false, isStreaming: true }))).toBe( + false + ); + }); +}); diff --git a/apps/mobile/src/components/agents/session-keep-awake.ts b/apps/mobile/src/components/agents/session-keep-awake.ts new file mode 100644 index 0000000000..ea766b4092 --- /dev/null +++ b/apps/mobile/src/components/agents/session-keep-awake.ts @@ -0,0 +1,26 @@ +/** + * Wake-lock window for the session screen. + * + * The preference only gates the existing window; it never widens it. Focus bounds + * the window to the visible working UI, so a backgrounded or covered screen never + * holds the OS idle timer. + */ +export function shouldKeepSessionAwake( + input: Readonly<{ + keepScreenOn: boolean; + /** False until the stored preference has been read; treated as "not yet on". */ + preferenceLoaded: boolean; + isFocused: boolean; + isDisconnected: boolean; + isStreaming: boolean; + pendingMessageCount: number; + }> +): boolean { + return ( + input.keepScreenOn && + input.preferenceLoaded && + input.isFocused && + !input.isDisconnected && + (input.isStreaming || input.pendingMessageCount > 0) + ); +} diff --git a/apps/mobile/src/components/agents/session-transcript.test.ts b/apps/mobile/src/components/agents/session-transcript.test.ts index 5c225b6bc7..012149a108 100644 --- a/apps/mobile/src/components/agents/session-transcript.test.ts +++ b/apps/mobile/src/components/agents/session-transcript.test.ts @@ -1,8 +1,10 @@ +/* eslint-disable max-lines -- Marker rules need one fixture per state; the file is a single builder harness. */ import { describe, expect, it } from 'vitest'; import { getSessionTranscriptItemKey, mergeSessionTranscript, + TRANSCRIPT_TIME_MARKER_GAP_MS, } from '@/components/agents/session-transcript'; function message(id: string) { @@ -75,6 +77,81 @@ function warmReuseAttempt(id: string, triggerMessageId: string) { }; } +function userMessageAt(id: string, created: number) { + const base = message(id); + base.info.time = { created }; + return base; +} + +function assistantMessageWithTextAt(id: string, created: number) { + return { + info: { + id, + sessionID: 'ses_12345678901234567890123456', + role: 'assistant' as const, + time: { created }, + parentID: 'm0', + modelID: 'model', + 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: [ + { + id: `${id}:text`, + sessionID: 'ses_12345678901234567890123456', + messageID: id, + type: 'text' as const, + text: 'visible', + }, + ], + }; +} + +function assistantMessageWithStepStartOnly(id: string, created: number) { + return { + info: { + id, + sessionID: 'ses_12345678901234567890123456', + role: 'assistant' as const, + time: { created }, + parentID: 'm0', + modelID: 'model', + 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: [ + { + id: `${id}:step-start`, + sessionID: 'ses_12345678901234567890123456', + messageID: id, + type: 'step-start' as const, + }, + ], + }; +} + +function userMessageWithCreatedAt(id: string, created: number | undefined) { + const base = message(id); + if (created === undefined) { + (base.info.time as { created?: number }).created = undefined; + } else { + base.info.time = { created }; + } + return base; +} + +function keysOf(items: ReturnType): string[] { + return items.map(item => getSessionTranscriptItemKey(item)); +} + describe('session transcript', () => { it('places preparation attempts after their trigger message', () => { const messages = [message('msg_001'), message('msg_002')]; @@ -82,7 +159,8 @@ describe('session transcript', () => { const transcript = mergeSessionTranscript(messages, attempts); - expect(transcript.map(item => getSessionTranscriptItemKey(item))).toEqual([ + expect(keysOf(transcript)).toEqual([ + 'time:msg_001', 'msg_001', 'preparation:attempt_001', 'msg_002', @@ -95,10 +173,7 @@ describe('session transcript', () => { [attempt('attempt_older', 'msg_001')] ); - expect(transcript.map(item => getSessionTranscriptItemKey(item))).toEqual([ - 'msg_011', - 'preparation:attempt_older', - ]); + expect(keysOf(transcript)).toEqual(['time:msg_011', 'msg_011', 'preparation:attempt_older']); }); it('hides warm-reuse completed attempts that only ran synthetic sandbox markers', () => { @@ -107,7 +182,7 @@ describe('session transcript', () => { [warmReuseAttempt('attempt_warm', 'msg_001')] ); - expect(transcript.map(item => getSessionTranscriptItemKey(item))).toEqual(['msg_001']); + expect(keysOf(transcript)).toEqual(['time:msg_001', 'msg_001']); }); it('keeps a running attempt even if it only has synthetic markers so far', () => { @@ -118,9 +193,200 @@ describe('session transcript', () => { }; const transcript = mergeSessionTranscript([message('msg_001')], [running]); - expect(transcript.map(item => getSessionTranscriptItemKey(item))).toEqual([ - 'msg_001', - 'preparation:attempt_running', + expect(keysOf(transcript)).toEqual(['time:msg_001', 'msg_001', 'preparation:attempt_running']); + }); + + it('opens a burst of ten visible messages inside one minute with exactly one marker, the first item', () => { + const messages = Array.from({ length: 10 }, (_, i) => + userMessageAt(`msg_burst_${i}`, 1_000_000_000 + i * 1000) + ); + + const transcript = mergeSessionTranscript(messages, []); + + expect(keysOf(transcript)).toEqual(['time:msg_burst_0', ...messages.map(m => m.info.id)]); + expect(transcript.filter(item => item.type === 'time')).toHaveLength(1); + expect(transcript[0]).toMatchObject({ type: 'time', messageId: 'msg_burst_0' }); + }); + + it('marks a resumption when the gap reaches the threshold, and not one millisecond below it', () => { + const base = 1_000_000_000; + + const atGap = mergeSessionTranscript( + [ + userMessageAt('msg_gap_a', base), + userMessageAt('msg_gap_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), + ], + [] + ); + expect(keysOf(atGap)).toEqual(['time:msg_gap_a', 'msg_gap_a', 'time:msg_gap_b', 'msg_gap_b']); + + const belowGap = mergeSessionTranscript( + [ + userMessageAt('msg_gap_c', base), + userMessageAt('msg_gap_d', base + TRANSCRIPT_TIME_MARKER_GAP_MS - 1), + ], + [] + ); + expect(keysOf(belowGap)).toEqual(['time:msg_gap_c', 'msg_gap_c', 'msg_gap_d']); + }); + + it('marks a day change even when the gap is small, carrying dayChanged on the marker', () => { + const beforeMidnight = new Date(2026, 0, 1, 23, 59, 30).getTime(); + const afterMidnight = new Date(2026, 0, 2, 0, 0, 10).getTime(); + + const transcript = mergeSessionTranscript( + [userMessageAt('msg_day_a', beforeMidnight), userMessageAt('msg_day_b', afterMidnight)], + [] + ); + + expect(keysOf(transcript)).toEqual([ + 'time:msg_day_a', + 'msg_day_a', + 'time:msg_day_b', + 'msg_day_b', + ]); + expect(transcript[0]).toMatchObject({ type: 'time', dayChanged: false }); + expect(transcript[2]).toMatchObject({ type: 'time', dayChanged: true }); + }); + + it('carries dayChanged false on every marker except a true day change', () => { + const beforeMidnight = new Date(2026, 0, 1, 23, 59, 30).getTime(); + const afterMidnight = new Date(2026, 0, 2, 0, 0, 10).getTime(); + const later = afterMidnight + 60_000; + + const transcript = mergeSessionTranscript( + [ + userMessageAt('msg_dc_a', beforeMidnight), + userMessageAt('msg_dc_b', afterMidnight), + userMessageAt('msg_dc_c', later), + ], + [] + ); + + const markers = transcript.filter(item => item.type === 'time'); + expect(markers.map(marker => marker.dayChanged)).toEqual([false, true]); + }); + + it('drops an invisible message and its would-be marker, keeping the surviving marker count', () => { + const base = 1_000_000_000; + const withoutInvisible = mergeSessionTranscript( + [ + userMessageAt('msg_vis_a', base), + userMessageAt('msg_vis_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), + ], + [] + ); + const withInvisible = mergeSessionTranscript( + [ + userMessageAt('msg_vis_a', base), + assistantMessageWithStepStartOnly('msg_hidden', base + 10_000), + userMessageAt('msg_vis_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), + ], + [] + ); + + expect(keysOf(withInvisible)).toEqual([ + 'time:msg_vis_a', + 'msg_vis_a', + 'time:msg_vis_b', + 'msg_vis_b', + ]); + expect(keysOf(withInvisible)).toEqual(keysOf(withoutInvisible)); + }); + + it('keeps an invalid-timestamp message visible without a marker and without resetting the run', () => { + const base = 1_000_000_000; + const transcript = mergeSessionTranscript( + [ + userMessageAt('msg_time_a', base), + userMessageWithCreatedAt('msg_time_b', undefined), + userMessageAt('msg_time_c', base + 2000), + ], + [] + ); + + expect(keysOf(transcript)).toEqual([ + 'time:msg_time_a', + 'msg_time_a', + 'msg_time_b', + 'msg_time_c', + ]); + + const maxValueTranscript = mergeSessionTranscript( + [ + userMessageAt('msg_max_a', base), + userMessageWithCreatedAt('msg_max_b', Number.MAX_VALUE), + userMessageAt('msg_max_c', base + 2000), + ], + [] + ); + expect(keysOf(maxValueTranscript)).toEqual([ + 'time:msg_max_a', + 'msg_max_a', + 'msg_max_b', + 'msg_max_c', ]); }); + + it('keeps every fixture free of a trailing marker and of adjacent markers', () => { + const base = 1_000_000_000; + const beforeMidnight = new Date(2026, 0, 1, 23, 59, 30).getTime(); + const afterMidnight = new Date(2026, 0, 2, 0, 0, 10).getTime(); + + const transcripts = [ + mergeSessionTranscript( + [message('msg_001'), message('msg_002')], + [attempt('attempt_001', 'msg_001')] + ), + mergeSessionTranscript([message('msg_011')], [attempt('attempt_older', 'msg_001')]), + mergeSessionTranscript([message('msg_001')], [warmReuseAttempt('attempt_warm', 'msg_001')]), + mergeSessionTranscript( + [ + userMessageAt('msg_gap_a', base), + userMessageAt('msg_gap_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), + ], + [] + ), + mergeSessionTranscript( + [userMessageAt('msg_day_a', beforeMidnight), userMessageAt('msg_day_b', afterMidnight)], + [] + ), + mergeSessionTranscript( + [ + userMessageAt('msg_vis_a', base), + assistantMessageWithStepStartOnly('msg_hidden', base + 10_000), + userMessageAt('msg_vis_b', base + TRANSCRIPT_TIME_MARKER_GAP_MS), + ], + [] + ), + mergeSessionTranscript( + [ + userMessageAt('msg_time_a', base), + userMessageWithCreatedAt('msg_time_b', undefined), + userMessageAt('msg_time_c', base + 2000), + ], + [] + ), + ]; + + for (const transcript of transcripts) { + const keys = keysOf(transcript); + expect(keys.at(-1)?.startsWith('time:')).toBe(false); + for (let i = 1; i < keys.length; i += 1) { + expect(keys[i - 1]?.startsWith('time:') && keys[i]?.startsWith('time:')).toBe(false); + } + } + }); + + it('renders visible assistant messages and markers alongside user messages', () => { + const transcript = mergeSessionTranscript( + [ + userMessageAt('msg_user', 1_000_000_000), + assistantMessageWithTextAt('msg_asst', 1_000_000_100), + ], + [] + ); + + expect(keysOf(transcript)).toEqual(['time:msg_user', 'msg_user', 'msg_asst']); + }); }); diff --git a/apps/mobile/src/components/agents/session-transcript.ts b/apps/mobile/src/components/agents/session-transcript.ts index 045071139a..d2d444647a 100644 --- a/apps/mobile/src/components/agents/session-transcript.ts +++ b/apps/mobile/src/components/agents/session-transcript.ts @@ -1,12 +1,31 @@ import { isNoOpCompletedPreparationAttempt } from '@kilocode/cloud-agent-sdk/preparation-attempts'; import { type PreparationAttempt, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { isSameLocalDay, isValidTranscriptTime } from './message-time-label'; +import { messageRendersContent } from './message-visibility'; + export type SessionTranscriptItem = | { type: 'message'; message: StoredMessage } - | { type: 'preparation'; attempt: PreparationAttempt }; + | { type: 'preparation'; attempt: PreparationAttempt } + | { type: 'time'; created: number; messageId: string; dayChanged: boolean }; + +/** + * A time marker opens a run of messages. Below this gap the messages belong to the + * same working burst and repeat the same minute, so a second marker adds nothing. + * Evidence: in the user's transcript one agent turn stepped 3:37 → 3:42 → 3:49 → + * 3:53, so the largest gap inside a live turn was 7 minutes. Ten minutes keeps a + * live turn under one marker and still marks a real pause. + */ +export const TRANSCRIPT_TIME_MARKER_GAP_MS = 10 * 60 * 1000; export function getSessionTranscriptItemKey(item: SessionTranscriptItem): string { - return item.type === 'message' ? item.message.info.id : `preparation:${item.attempt.id}`; + if (item.type === 'message') { + return item.message.info.id; + } + if (item.type === 'preparation') { + return `preparation:${item.attempt.id}`; + } + return `time:${item.messageId}`; } export function mergeSessionTranscript( @@ -29,9 +48,27 @@ export function mergeSessionTranscript( const items: SessionTranscriptItem[] = []; const messageIds = new Set(); + let previousCreated: number | undefined = undefined; for (const message of messages) { messageIds.add(message.info.id); - items.push({ type: 'message', message }); + if (messageRendersContent(message)) { + const created = message.info.time.created; + // One validity rule, shared with the marker component: a timestamp the label + // cannot format must never produce a marker row. + if (isValidTranscriptTime(created)) { + const dayChanged = + previousCreated !== undefined && !isSameLocalDay(created, previousCreated); + if ( + previousCreated === undefined || + dayChanged || + created - previousCreated >= TRANSCRIPT_TIME_MARKER_GAP_MS + ) { + items.push({ type: 'time', created, messageId: message.info.id, dayChanged }); + } + previousCreated = created; + } + items.push({ type: 'message', message }); + } for (const attempt of byMessageId.get(message.info.id) ?? []) { items.push({ type: 'preparation', attempt }); } diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx index 2a53eb29d9..70fe73cdbb 100644 --- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx @@ -32,9 +32,7 @@ export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) { ) : null} - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts index 3cd46bcfc8..ea9a5c9d0b 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts @@ -251,7 +251,11 @@ describe('EditToolCardBody — diff preview routing', () => { part: makeEditPart({ oldString: 'old', newString: 'new' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); - expect(findByType(root, 'MonoScrollBlock')).toHaveLength(2); + const blocks = findByType(root, 'MonoScrollBlock'); + expect(blocks).toHaveLength(2); + for (const block of blocks) { + expect((block.props as { maxLength?: unknown }).maxLength).toBeUndefined(); + } }); it('renders no body when the model does not exist and strings are empty', () => { diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx index b557298c57..966cfd48de 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx @@ -22,7 +22,6 @@ function EditFallbackBody({ {oldString.length > 0 ? ( @@ -30,7 +29,6 @@ function EditFallbackBody({ {newString.length > 0 ? ( diff --git a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx index 20f742d1fa..b18d1ffa03 100644 --- a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx @@ -36,15 +36,9 @@ export function GenericToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( {inputStr ? ( - - ) : null} - {output ? ( - + ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx index c45dafcf02..ba6cd1b2c7 100644 --- a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx @@ -23,9 +23,7 @@ export function GlobToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx index a4cae02f92..2c9cdd5b52 100644 --- a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx @@ -23,9 +23,7 @@ export function GrepToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx index 714e52e399..eb49e9fd5d 100644 --- a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx @@ -23,9 +23,7 @@ export function ListToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx index f134fb67ae..04e719bd6f 100644 --- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx @@ -35,7 +35,7 @@ export function ReadToolCardBody({ part }: Readonly<{ part: ToolPart }>) { {/* An image read's output is only "Image read successfully" — the image itself is the content, so the mono block would be noise (plan D10). */} {markdownPreview === undefined && !hasImages && output ? ( - + ) : null} {error ? ( diff --git a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx index 7238fa1866..a9adef67b8 100644 --- a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx @@ -23,9 +23,7 @@ export function TaskToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx index 8310a6243c..90ff0899e6 100644 --- a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx @@ -23,9 +23,7 @@ export function TodoToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts b/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts new file mode 100644 index 0000000000..04a9df72a4 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-cards/tool-card-output-cap.test.ts @@ -0,0 +1,208 @@ +import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { BashToolCardBody } from './bash-tool-card'; +import { GenericToolCardBody } from './generic-tool-card'; +import { GlobToolCardBody } from './glob-tool-card'; +import { GrepToolCardBody } from './grep-tool-card'; +import { ListToolCardBody } from './list-tool-card'; +import { ReadToolCardBody } from './read-tool-card'; +import { TaskToolCardBody } from './task-tool-card'; +import { TodoToolCardBody } from './todo-tool-card'; +import { WebSearchToolCardBody } from './web-search-tool-card'; +import { prepareMonoScrollContent } from '../mono-scroll-block-model'; + +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('lucide-react-native', () => ({ + Terminal: 'Terminal', + Search: 'Search', + FileSearch: 'FileSearch', + FolderOpen: 'FolderOpen', + Eye: 'Eye', + Cpu: 'Cpu', + ListTodo: 'ListTodo', + Globe: 'Globe', + Plug: 'Plug', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('../bubble-text-selection-context', () => ({ + useTranscriptTextSelectable: () => true, +})); +vi.mock('../mono-scroll-block', () => ({ MonoScrollBlock: 'MonoScrollBlock' })); +vi.mock('../fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('../open-part-detail-context', () => ({ useOpenPartDetail: () => openSpy })); +vi.mock('../tool-card-display', () => ({ getToolDisplay, toolPartHasDetails })); +vi.mock('../read-markdown-preview', () => ({ ReadMarkdownPreview: 'ReadMarkdownPreview' })); + +const { + getToolImageAttachments, + isMarkdownPath, + resolveMarkdownPreview, + openSpy, + getToolDisplay, + toolPartHasDetails, +} = vi.hoisted(() => ({ + getToolImageAttachments: vi.fn(() => []), + isMarkdownPath: vi.fn(() => false), + resolveMarkdownPreview: vi.fn(), + openSpy: vi.fn(), + getToolDisplay: vi.fn(), + toolPartHasDetails: vi.fn(), +})); +vi.mock('../tool-card-attachments', () => ({ getToolImageAttachments })); +vi.mock('../read-tool-markdown', () => ({ isMarkdownPath, resolveMarkdownPreview })); + +/** + * A 20000-character output with one 4000-character line and no newline in it, + * so a truncated render would be provable by length alone. + */ +const longOutput = `${'x'.repeat(4000)}\n${'y'.repeat(15_999)}`; + +function makeCompletedPart(tool: string, input: Record): ToolPart { + return { + id: `${tool}-1`, + sessionID: 'session-1', + messageID: 'message-1', + type: 'tool', + callID: 'call-1', + tool, + state: { + status: 'completed', + input, + output: longOutput, + title: tool, + metadata: {}, + time: { start: 0, end: 1 }, + }, + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + const props = value.props as Record; + // Walk the rendered output of function components so their + // children are visible to predicate matching. + if (typeof value.type === 'function') { + walk((value.type as React.FunctionComponent)(props)); + } + walk(props.children); + } + } + walk(node); + return matches; +} + +function findByType(root: React.ReactElement, type: string): React.ReactElement[] { + return findAll(root, el => el.type === type); +} + +type BodyCase = { + name: string; + body: (props: { part: ToolPart }) => React.ReactElement; + part: ToolPart; + /** Mono blocks per body: the output block, plus the input JSON for generic. */ + blockCount: number; +}; + +const bodies: BodyCase[] = [ + { + name: 'BashToolCardBody', + body: BashToolCardBody, + part: makeCompletedPart('bash', { command: 'echo hi' }), + blockCount: 1, + }, + { + name: 'GlobToolCardBody', + body: GlobToolCardBody, + part: makeCompletedPart('glob', {}), + blockCount: 1, + }, + { + name: 'GrepToolCardBody', + body: GrepToolCardBody, + part: makeCompletedPart('grep', {}), + blockCount: 1, + }, + { + name: 'ListToolCardBody', + body: ListToolCardBody, + part: makeCompletedPart('list', {}), + blockCount: 1, + }, + { + // A markdown path would route to ReadMarkdownPreview instead of the mono block. + name: 'ReadToolCardBody', + body: ReadToolCardBody, + part: makeCompletedPart('read', { filePath: 'src/main.ts' }), + blockCount: 1, + }, + { + name: 'TaskToolCardBody', + body: TaskToolCardBody, + part: makeCompletedPart('task', {}), + blockCount: 1, + }, + { + name: 'TodoToolCardBody', + body: TodoToolCardBody, + part: makeCompletedPart('todoread', {}), + blockCount: 1, + }, + { + name: 'WebSearchToolCardBody', + body: WebSearchToolCardBody, + part: makeCompletedPart('websearch', {}), + blockCount: 1, + }, + { + // Generic renders the input JSON block and the output block. + name: 'GenericToolCardBody', + body: GenericToolCardBody, + part: makeCompletedPart('generic', { command: 'echo hi' }), + blockCount: 2, + }, +]; + +describe('tool-card output caps removed', () => { + it.each(bodies)( + '$name passes the full output to MonoScrollBlock with no cap', + ({ body, part, blockCount }) => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = body({ part }) as unknown as React.ReactElement; + const blocks = findByType(root, 'MonoScrollBlock'); + expect(blocks).toHaveLength(blockCount); + for (const block of blocks) { + expect((block.props as { maxLength?: unknown }).maxLength).toBeUndefined(); + } + const outputBlocks = blocks.filter( + block => (block.props as { content?: unknown }).content === longOutput + ); + expect(outputBlocks).toHaveLength(1); + } + ); + + it('prepareMonoScrollContent keeps the full output when maxLength is undefined', () => { + expect(prepareMonoScrollContent(longOutput, undefined)).toEqual({ + displayText: longOutput, + isTruncated: false, + }); + }); +}); diff --git a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx index ca119c9533..47b7e70505 100644 --- a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx @@ -24,9 +24,7 @@ export function WebSearchToolCardBody({ part }: Readonly<{ part: ToolPart }>) { return ( - {output ? ( - - ) : null} + {output ? : null} {error ? ( {error} diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts index 56d87d2d93..cac1ce6e09 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts @@ -239,7 +239,13 @@ describe('WriteToolCardBody — diff preview routing', () => { part: makeWritePart({ content: 'hello' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); - expect(findByType(root, 'MonoScrollBlock')).toHaveLength(1); + const blocks = findByType(root, 'MonoScrollBlock'); + expect(blocks).toHaveLength(1); + const block = blocks[0]; + if (!block) { + throw new Error('block not found'); + } + expect((block.props as { maxLength?: unknown }).maxLength).toBeUndefined(); }); it('renders no body when the model does not exist and content is empty', () => { diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx index 1942e03ede..df568e0112 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx @@ -32,7 +32,7 @@ export function WriteToolCardBody({ part }: Readonly<{ part: ToolPart }>) { if (diffModel) { body = ; } else if (content.length > 0) { - body = ; + body = ; } return ( diff --git a/apps/mobile/src/components/agents/transcript-time-marker.tsx b/apps/mobile/src/components/agents/transcript-time-marker.tsx new file mode 100644 index 0000000000..f393a58402 --- /dev/null +++ b/apps/mobile/src/components/agents/transcript-time-marker.tsx @@ -0,0 +1,30 @@ +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +import { formatTranscriptMarkerLabel } from './message-time-label'; + +/** + * Centered, muted time marker that opens a run of messages. Same type treatment as + * `CompactionSeparator`, without its hairlines: a marker recurs through the + * transcript, so the rules would read as noise. + */ +export function TranscriptTimeMarker({ + created, + dayChanged, +}: Readonly<{ created: number; dayChanged: boolean }>) { + // Date.now() at render time only, exactly as the message label did: no timer and + // no day-boundary watcher, so a marker mounted across midnight keeps its text + // until the next render. + const label = formatTranscriptMarkerLabel(created, Date.now(), dayChanged); + if (label === null) { + return null; + } + return ( + + + {label} + + + ); +} diff --git a/apps/mobile/src/components/preferences-screen.tsx b/apps/mobile/src/components/preferences-screen.tsx new file mode 100644 index 0000000000..fbf09db735 --- /dev/null +++ b/apps/mobile/src/components/preferences-screen.tsx @@ -0,0 +1,91 @@ +import { Brain, type LucideIcon, Smartphone } from 'lucide-react-native'; +import { Switch, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { TabScreenScrollView } from '@/components/tab-screen'; +import { Text } from '@/components/ui/text'; +import { useKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; +import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type PreferenceRowProps = Readonly<{ + icon: LucideIcon; + title: string; + subtitle: string; + value: boolean; + disabled: boolean; + onValueChange: (next: boolean) => void; +}>; + +/** Switch row shaped like the Notifications category row. */ +function PreferenceRow({ + icon: Icon, + title, + subtitle, + value, + disabled, + onValueChange, +}: PreferenceRowProps) { + const colors = useThemeColors(); + return ( + + + + {title} + + {subtitle} + + + + + ); +} + +export function PreferencesScreen() { + const { + defaultExpanded, + hasLoaded: reasoningLoaded, + setDefaultExpanded, + } = useReasoningPreference(); + const { + keepScreenOn, + hasLoaded: keepScreenOnLoaded, + setKeepScreenOn, + } = useKeepScreenOnPreference(); + + return ( + + + + + + + + ); +} diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 2437b4c8dc..2d19550cea 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -12,6 +12,7 @@ import { LogOut, MessageSquare, ShieldCheck, + SlidersHorizontal, Trash2, } from 'lucide-react-native'; import { Alert, Platform, View } from 'react-native'; @@ -291,6 +292,23 @@ export function ProfileScreen() { /> + {/* App */} + + + App + + { + router.push('/(app)/(tabs)/(3_profile)/preferences' as Href); + }} + /> + + {/* Notifications */} diff --git a/apps/mobile/src/lib/auth/auth-context.test.ts b/apps/mobile/src/lib/auth/auth-context.test.ts index a2e28c5c6e..580650f92b 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.ts +++ b/apps/mobile/src/lib/auth/auth-context.test.ts @@ -31,6 +31,9 @@ vi.mock('@/lib/appsflyer', () => ({ resetAppsFlyerState: vi.fn(), trackEvent: vi vi.mock('@sentry/react-native', () => ({ setUser: vi.fn() })); vi.mock('@/lib/telemetry/controller', () => ({ clearTelemetryDecision: vi.fn() })); vi.mock('@/lib/telemetry/posthog-storage', () => ({ purgePostHogPersistence: vi.fn() })); +// sonner-native pulls in react-native at runtime, whose Flow-only `import +// typeof` syntax crashes Node's parser in the pure (node) test environment. +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ clearAgentModelPreference: vi.fn(), diff --git a/apps/mobile/src/lib/auth/auth-context.test.tsx b/apps/mobile/src/lib/auth/auth-context.test.tsx index b55c18c4c4..0dfa64c554 100644 --- a/apps/mobile/src/lib/auth/auth-context.test.tsx +++ b/apps/mobile/src/lib/auth/auth-context.test.tsx @@ -99,9 +99,13 @@ vi.mock('@/lib/hooks/use-persisted-agent-model', () => ({ clearAgentModelPreference: vi.fn(), })); -vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ +const { clearKeepScreenOnPreference, clearReasoningPreference } = vi.hoisted(() => ({ + clearKeepScreenOnPreference: vi.fn(), clearReasoningPreference: vi.fn(), })); +vi.mock('@/lib/hooks/use-keep-screen-on-preference', () => ({ clearKeepScreenOnPreference })); + +vi.mock('@/lib/hooks/use-reasoning-preference', () => ({ clearReasoningPreference })); vi.mock('@/lib/last-active-instance', () => ({ clearLastActiveInstance: vi.fn().mockResolvedValue(undefined), @@ -125,6 +129,7 @@ vi.mock('@/lib/pr-review/viewed-files', () => ({ vi.mock('@/lib/storage-keys', () => ({ AUTH_TOKEN_KEY: 'auth-token', + KEEP_SCREEN_ON_KEY: 'keep-session-screen-on', KILOCLAW_OWNED_KEY: 'kiloclaw-owned', LEGACY_EXCHANGE_DONE_KEY: 'legacy-exchange-done', NOTIFICATION_PROMPT_SEEN_KEY: 'notification-prompt-seen', @@ -289,6 +294,17 @@ describe('sign-out teardown ordering', () => { unmount(); }); + it('clears both local preferences on sign-out', async () => { + const { ctx } = await mountAndGetContext(); + + await act(async () => { + await ctx.signOut(); + }); + + expect(clearKeepScreenOnPreference).toHaveBeenCalled(); + expect(clearReasoningPreference).toHaveBeenCalled(); + }); + it('closes the ownership gate before any await and blocks a late persist', async () => { const { ctx, unmount } = await mountAndGetContext(); const ownership = await import('@/lib/kiloclaw-tab-ownership'); diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 494754358a..4ed5d21284 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -19,6 +19,7 @@ import { queryClient } from '@/lib/query-client'; import { setTrpcUnauthorizedHandler } from '@/lib/auth/trpc-unauthorized'; import { exchangeLegacyToken } from '@/lib/auth/exchange-legacy-token'; import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model'; +import { clearKeepScreenOnPreference } from '@/lib/hooks/use-keep-screen-on-preference'; import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; import { clearKiloClawOwned, gateKiloClawOwned } from '@/lib/kiloclaw-tab-ownership'; import { clearLastActiveInstance } from '@/lib/last-active-instance'; @@ -273,6 +274,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { await clearViewedFiles(); clearAgentModelPreference(); clearReasoningPreference(); + clearKeepScreenOnPreference(); queryClient.clear(); setSessionEnded(ended); setToken(undefined); diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.test.ts b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts index 77bb0a3c6e..3f70e4b05f 100644 --- a/apps/mobile/src/lib/hooks/secure-store-preference.test.ts +++ b/apps/mobile/src/lib/hooks/secure-store-preference.test.ts @@ -88,4 +88,28 @@ describe('createSecureStorePreference', () => { expect(store.get()).toBe(false); unsubscribe(); }); + + it('keeps the default when clear() runs during an in-flight initial load', async () => { + const pendingReads: ((raw: string | null) => void)[] = []; + getItemAsync.mockReturnValue( + new Promise(resolve => { + pendingReads.push(resolve); + }) + ); + const store = createSecureStorePreference({ + key: 'k', + defaultValue: false, + parse: raw => raw === 'true', + serialize: value => (value ? 'true' : 'false'), + }); + + const unsubscribe = store.subscribe(noopListener); + store.clear(); + pendingReads[0]?.('true'); + await flushMicrotasks(); + + expect(deleteItemAsync).toHaveBeenCalled(); + expect(store.get()).toBe(false); + unsubscribe(); + }); }); diff --git a/apps/mobile/src/lib/hooks/secure-store-preference.ts b/apps/mobile/src/lib/hooks/secure-store-preference.ts index 269b9501c0..1f57708fd9 100644 --- a/apps/mobile/src/lib/hooks/secure-store-preference.ts +++ b/apps/mobile/src/lib/hooks/secure-store-preference.ts @@ -16,7 +16,8 @@ export function createSecureStorePreference(options: { const { key, defaultValue, parse, serialize } = options; let value = defaultValue; let hasLoaded = false; - // A set() before the initial load resolves must win over the disk value. + // A set() or clear() before the initial load resolves must win over the + // disk value. let dirty = false; let loadStarted = false; const listeners = new Set<() => void>(); @@ -86,7 +87,9 @@ export function createSecureStorePreference(options: { /** Reset memory and disk (e.g. on sign-out). */ clear: () => { value = defaultValue; - dirty = false; + // Keep dirty set so an in-flight initial read does not restore the old + // value after sign-out. + dirty = true; emit(); void remove(); }, diff --git a/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.test.ts b/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.test.ts new file mode 100644 index 0000000000..3be1b1ca9c --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.test.ts @@ -0,0 +1,109 @@ +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: 'keep-session-screen-on', + defaultValue: true, + parse: raw => raw !== 'false', + serialize: value => (value ? 'true' : 'false'), + }) + ); +} + +describe('parseKeepScreenOn', () => { + it('defaults to on for a missing value (fresh install and unreadable read)', async () => { + const { parseKeepScreenOn } = await import('./use-keep-screen-on-preference'); + expect(parseKeepScreenOn(null)).toBe(true); + }); + + it("reads 'true' as on", async () => { + const { parseKeepScreenOn } = await import('./use-keep-screen-on-preference'); + expect(parseKeepScreenOn('true')).toBe(true); + }); + + it("reads 'false' as off — the only value that turns the preference off", async () => { + const { parseKeepScreenOn } = await import('./use-keep-screen-on-preference'); + expect(parseKeepScreenOn('false')).toBe(false); + }); + + it('treats any other stored string as on', async () => { + const { parseKeepScreenOn } = await import('./use-keep-screen-on-preference'); + expect(parseKeepScreenOn('')).toBe(true); + expect(parseKeepScreenOn('nonsense')).toBe(true); + }); +}); + +describe('keep-screen-on store', () => { + beforeEach(() => { + getItemAsync.mockReset(); + setItemAsync.mockReset(); + deleteItemAsync.mockReset(); + captureException.mockReset(); + toastError.mockReset(); + }); + + it('defaults to on when SecureStore returns null', async () => { + getItemAsync.mockResolvedValue(null); + const store = await makeStore(); + + const unsubscribe = store.subscribe(noopListener); + await flushMicrotasks(); + + expect(store.get()).toBe(true); + expect(store.getHasLoaded()).toBe(true); + unsubscribe(); + }); + + it("turns off only for the stored string 'false'", async () => { + getItemAsync.mockResolvedValue('false'); + const store = await makeStore(); + + const unsubscribe = store.subscribe(noopListener); + await flushMicrotasks(); + + expect(store.get()).toBe(false); + 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(false); + expect(setItemAsync).toHaveBeenCalledWith('keep-session-screen-on', 'false'); + + store.set(true); + expect(setItemAsync).toHaveBeenCalledWith('keep-session-screen-on', 'true'); + + store.clear(); + expect(deleteItemAsync).toHaveBeenCalledWith('keep-session-screen-on'); + expect(store.get()).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.ts b/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.ts new file mode 100644 index 0000000000..d05f0020f7 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-keep-screen-on-preference.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from 'react'; + +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { KEEP_SCREEN_ON_KEY } from '@/lib/storage-keys'; + +/** + * Default-on preference: only the exact stored string 'false' turns it off, so a + * missing or unreadable value keeps the screen-awake behavior the app ships with. + */ +export function parseKeepScreenOn(raw: string | null): boolean { + return raw !== 'false'; +} + +const store = createSecureStorePreference({ + key: KEEP_SCREEN_ON_KEY, + defaultValue: true, + parse: parseKeepScreenOn, + serialize: value => (value ? 'true' : 'false'), +}); + +export function clearKeepScreenOnPreference() { + store.clear(); +} + +function setKeepScreenOn(value: boolean) { + store.set(value); +} + +export function useKeepScreenOnPreference() { + const keepScreenOn = useSyncExternalStore(store.subscribe, store.get); + const hasLoaded = useSyncExternalStore(store.subscribe, store.getHasLoaded); + return { keepScreenOn, hasLoaded, setKeepScreenOn }; +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index 5b76a09ee6..9c014e19e0 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -18,6 +18,7 @@ export const REVIEW_REQUESTED_AT_KEY = 'store-review-requested-at'; export const PR_REVIEW_RECENTS_KEY = 'pr-review-recents'; export const PR_REVIEW_VIEWED_KEY = 'pr-review-viewed'; export const THEME_PREFERENCE_KEY = 'theme-preference'; +export const KEEP_SCREEN_ON_KEY = 'keep-session-screen-on'; export const KILOCLAW_OWNED_KEY = 'kiloclaw-owned'; export const REFRESH_TOKEN_KEY = 'auth-refresh-token'; export const TOKEN_EXPIRES_AT_KEY = 'auth-token-expires-at';