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';