From 274360a3f5a73b8f3831aefd6183a5212bfef4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 23:08:18 +0200 Subject: [PATCH 1/4] fix(session-ingest): serve current DB titles for active sessions Overlay cli_sessions_v2.title onto /sessions/active heartbeat titles when the DB title is present and meaningful, falling back to the heartbeat title on a missing row, null/empty/whitespace title, or overlay-query failure. Also invalidate activeSessions.list on rename so the mobile Remote row updates without waiting for the next poll. --- .../src/lib/agent-session-cache.test.ts | 7 +- apps/mobile/src/lib/agent-session-cache.ts | 4 + .../session-ingest/src/routes/api.test.ts | 176 ++++++++++++++++++ services/session-ingest/src/routes/api.ts | 52 ++++++ 4 files changed, 238 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/agent-session-cache.test.ts b/apps/mobile/src/lib/agent-session-cache.test.ts index afe62d197a..40764371de 100644 --- a/apps/mobile/src/lib/agent-session-cache.test.ts +++ b/apps/mobile/src/lib/agent-session-cache.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; describe('invalidateAgentSessionQueries', () => { - it('invalidates session list and recent repository queries', async () => { + it('invalidates session list, recent repository, and active session queries', async () => { const listFilter = { queryKey: ['cliSessionsV2', 'list'] }; const recentRepositoriesFilter = { queryKey: ['cliSessionsV2', 'recentRepositories'] }; + const activeListFilter = { queryKey: ['activeSessions', 'list'] }; const queryClient = { invalidateQueries: vi.fn().mockResolvedValue(undefined), }; @@ -14,11 +15,15 @@ describe('invalidateAgentSessionQueries', () => { list: { pathFilter: () => listFilter }, recentRepositories: { pathFilter: () => recentRepositoriesFilter }, }, + activeSessions: { + list: { pathFilter: () => activeListFilter }, + }, }; await invalidateAgentSessionQueries(queryClient, trpc); expect(queryClient.invalidateQueries).toHaveBeenCalledWith(listFilter); expect(queryClient.invalidateQueries).toHaveBeenCalledWith(recentRepositoriesFilter); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith(activeListFilter); }); }); diff --git a/apps/mobile/src/lib/agent-session-cache.ts b/apps/mobile/src/lib/agent-session-cache.ts index 7ab5173fc7..a1e237a3a7 100644 --- a/apps/mobile/src/lib/agent-session-cache.ts +++ b/apps/mobile/src/lib/agent-session-cache.ts @@ -9,6 +9,9 @@ type AgentSessionTrpcQueries = { list: QueryPathFilter; recentRepositories: QueryPathFilter; }; + activeSessions: { + list: QueryPathFilter; + }; }; export async function invalidateAgentSessionQueries( @@ -18,5 +21,6 @@ export async function invalidateAgentSessionQueries( await Promise.all([ queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()), queryClient.invalidateQueries(trpc.cliSessionsV2.recentRepositories.pathFilter()), + queryClient.invalidateQueries(trpc.activeSessions.list.pathFilter()), ]); } diff --git a/services/session-ingest/src/routes/api.test.ts b/services/session-ingest/src/routes/api.test.ts index 1630c68407..52a5a757d4 100644 --- a/services/session-ingest/src/routes/api.test.ts +++ b/services/session-ingest/src/routes/api.test.ts @@ -1514,6 +1514,11 @@ describe('api routes', () => { connectionStub as unknown as ReturnType ); + // The title-overlay query is best-effort: a DB hit with no matching rows + // keeps the heartbeat titles intact, which is what this test asserts. + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + const app = makeApiApp(); const res = await app.fetch(new Request('http://local/sessions/active', { method: 'GET' }), { HYPERDRIVE: { connectionString: 'postgres://test' }, @@ -1521,6 +1526,15 @@ describe('api routes', () => { expect(res.status).toBe(200); expect(getUserConnectionDO).toHaveBeenCalledWith(expect.anything(), { kiloUserId: 'usr_test' }); + expect(fns.select).toHaveBeenCalledTimes(1); + expect(fns.selectWhere).toHaveBeenCalledTimes(1); + const predicate = fns.selectWhere.mock.calls[0]?.[0]; + expect(predicate).toBeInstanceOf(SQL); + const dialect = new PgDialect(); + const rendered = dialect.sqlToQuery(predicate as SQL); + expect(rendered.sql).toContain('"cli_sessions_v2"."kilo_user_id" = $1'); + expect(rendered.sql).toContain('"cli_sessions_v2"."session_id" in ($2)'); + expect(rendered.params).toEqual(['usr_test', 'ses_12345678901234567890123456']); expect(await res.json()).toEqual({ sessions: [ { @@ -1541,15 +1555,177 @@ describe('api routes', () => { connectionStub as unknown as ReturnType ); + // No active sessions means the overlay query must not run at all + // (saves a DB round-trip and avoids unnecessary noise). + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + const app = makeApiApp(); const res = await app.fetch(new Request('http://local/sessions/active', { method: 'GET' }), { HYPERDRIVE: { connectionString: 'postgres://test' }, }); expect(res.status).toBe(200); + expect(fns.select).not.toHaveBeenCalled(); expect(await res.json()).toEqual({ sessions: [] }); }); + describe('GET /sessions/active title overlay', () => { + const baseHeartbeat = (id: string, title: string) => ({ + id, + status: 'active', + title, + connectionId: `conn-${id}`, + }); + + function setupOverlayTest(overlayRows: Array<{ session_id: string; title: unknown }>) { + const connectionStub = { + getActiveSessions: vi.fn(async () => [ + baseHeartbeat('ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'Heartbeat A'), + baseHeartbeat('ses_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'Heartbeat B'), + ]), + }; + vi.mocked(getUserConnectionDO).mockReturnValue( + connectionStub as unknown as ReturnType + ); + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockResolvedValueOnce(overlayRows as never); + return { app: makeApiApp(), fns }; + } + + it('overlays a meaningful DB title onto the heartbeat session', async () => { + const { app, fns } = setupOverlayTest([ + { session_id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', title: 'Renamed Title' }, + ]); + + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions).toEqual([ + { + id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', + status: 'active', + title: 'Renamed Title', + connectionId: 'conn-ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + { + id: 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbb', + status: 'active', + title: 'Heartbeat B', + connectionId: 'conn-ses_bbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + ]); + // Overlay query was scoped to the authenticated user. + const predicate = fns.selectWhere.mock.calls[0]?.[0] as SQL; + const rendered = new PgDialect().sqlToQuery(predicate); + expect(rendered.sql).toContain('"cli_sessions_v2"."kilo_user_id" = $1'); + expect(rendered.params).toContain('usr_test'); + expect(rendered.params).toEqual( + expect.arrayContaining(['ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbb']) + ); + }); + + it('keeps the heartbeat title when no matching DB row exists', async () => { + const { app } = setupOverlayTest([]); + + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions.map(s => s.title)).toEqual(['Heartbeat A', 'Heartbeat B']); + }); + + it('keeps the heartbeat title when the DB title is null', async () => { + const { app } = setupOverlayTest([ + { session_id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', title: null }, + ]); + + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions.map(s => s.title)).toEqual(['Heartbeat A', 'Heartbeat B']); + }); + + it('keeps the heartbeat title when the DB title is an empty string', async () => { + const { app } = setupOverlayTest([ + { session_id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', title: '' }, + ]); + + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions.map(s => s.title)).toEqual(['Heartbeat A', 'Heartbeat B']); + }); + + it('keeps the heartbeat title when the DB title is whitespace-only', async () => { + const { app } = setupOverlayTest([ + { session_id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', title: ' ' }, + ]); + + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions.map(s => s.title)).toEqual(['Heartbeat A', 'Heartbeat B']); + }); + + it('returns 200 with heartbeat titles when the overlay query throws', async () => { + const connectionStub = { + getActiveSessions: vi.fn(async () => [ + baseHeartbeat('ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'Heartbeat A'), + ]), + }; + vi.mocked(getUserConnectionDO).mockReturnValue( + connectionStub as unknown as ReturnType + ); + const { db, fns } = makeDbFakes(); + vi.mocked(getWorkerDb).mockReturnValue(db); + fns.selectResult.mockRejectedValueOnce(new Error('db down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const app = makeApiApp(); + const res = await app.fetch( + new Request('http://local/sessions/active', { method: 'GET' }), + makeTestEnv() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { sessions: Array<{ id: string; title: string }> }; + expect(body.sessions).toEqual([ + { + id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', + status: 'active', + title: 'Heartbeat A', + connectionId: 'conn-ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + ]); + expect(warn).toHaveBeenCalledWith( + 'Failed to overlay active-session titles from Postgres (non-fatal)', + expect.objectContaining({ kiloUserId: 'usr_test', error: 'db down' }) + ); + warn.mockRestore(); + }); + }); + it('GET /user/cli returns 426 without Upgrade header', async () => { const app = makeApiApp(); const res = await app.fetch( diff --git a/services/session-ingest/src/routes/api.ts b/services/session-ingest/src/routes/api.ts index 2ebc8a759d..16dc59d5e3 100644 --- a/services/session-ingest/src/routes/api.ts +++ b/services/session-ingest/src/routes/api.ts @@ -515,6 +515,58 @@ api.get('/sessions/active', async c => { const kiloUserId = c.get('user_id'); const stub = getUserConnectionDO(c.env, { kiloUserId }); const sessions = await stub.getActiveSessions(); + + // Overlay the heartbeat snapshot titles with the latest persisted + // `cli_sessions_v2.title` for this user, so that a rename (which writes + // only to Postgres) is reflected on the next poll instead of waiting for + // the CLI's next heartbeat. The overlay is best-effort: a DB failure must + // not blank the entire Remote section, so we fall back to the heartbeat + // titles and return 200. + if (sessions.length > 0) { + const ids = sessions.map(s => s.id); + try { + const db = getWorkerDb(c.env.HYPERDRIVE.connectionString); + const titleRows = await db + .select({ + session_id: cli_sessions_v2.session_id, + title: cli_sessions_v2.title, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + inArray(cli_sessions_v2.session_id, ids) + ) + ); + + const titleBySessionId = new Map(); + for (const row of titleRows) { + // `cli_sessions_v2.title` is nullable, unconstrained text, and + // ingest can persist `''` or whitespace. Only honor DB titles that + // are actually meaningful; otherwise keep the heartbeat title so we + // never blank a valid row. + if (typeof row.title === 'string' && row.title.trim().length > 0) { + titleBySessionId.set(row.session_id, row.title); + } + } + + if (titleBySessionId.size > 0) { + for (const session of sessions) { + const dbTitle = titleBySessionId.get(session.id); + if (dbTitle !== undefined) { + session.title = dbTitle; + } + } + } + } catch (error) { + console.warn('Failed to overlay active-session titles from Postgres (non-fatal)', { + kiloUserId, + sessionCount: sessions.length, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return c.json({ sessions }, 200); }); From ed791cd547398becbbfb8e44455d4d4335505886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 23:08:30 +0200 Subject: [PATCH 2/4] feat(mobile): add scroll-to-bottom button to agent conversations Surface at-bottom detection as change-gated React state and render a themed circular button in session-message-list.tsx when scrolled up past the existing 100px threshold. Tapping animates back to the latest message; the button fades out via the same scroll-event detection. Content shorter than the viewport, and a fresh/reset session, are always treated as at-bottom. --- .../agents/session-message-list.tsx | 106 +++++++++++------- .../use-session-auto-scroll-state.test.ts | 47 ++++++++ .../agents/use-session-auto-scroll-state.ts | 14 +++ .../agents/use-session-list-auto-scroll.ts | 34 +++++- 4 files changed, 160 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index 1f44656049..3c166c4b5d 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -1,11 +1,14 @@ import { FlashList, type FlashListRef, type ListRenderItem } from '@shopify/flash-list'; import { type OlderMessagesError } from 'cloud-agent-sdk'; +import { ChevronDown } from 'lucide-react-native'; import { useCallback, useEffect, useRef } from 'react'; -import { type ViewStyle } from 'react-native'; +import { Pressable, View, type ViewStyle } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll'; import { SessionPaginationHeader } from '@/components/agents/session-pagination-header'; import { shouldTriggerOlderMessagesLoad } from '@/components/agents/session-message-list-state'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; const listStyle = { flex: 1 } satisfies ViewStyle; const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; @@ -46,7 +49,9 @@ export function SessionMessageList({ // message on first render and after prepended older pages, which is the // exact behavior we want for the agent session transcript. const { + isAtBottom, listRef, + scrollToLatestAnimated, handleContentSizeChange, handleListLayout, handleScroll, @@ -58,6 +63,7 @@ export function SessionMessageList({ itemCount: items.length, resetKey: sessionId, }); + const colors = useThemeColors(); // Coalesce the trigger: only fire `onLoadOlderMessages` while there is // actually a cursor, we are not already loading, and we are not in a @@ -98,42 +104,66 @@ export function SessionMessageList({ const listRefSafe = listRef as unknown as React.RefObject>; return ( - - ref={listRefSafe} - style={listStyle} - contentContainerStyle={listContentContainerStyle} - data={items} - keyExtractor={keyExtractor} - renderItem={renderItem} - onScroll={handleScroll} - onScrollBeginDrag={handleScrollBeginDrag} - onScrollEndDrag={handleScrollEndDrag} - onMomentumScrollBegin={handleMomentumScrollBegin} - onMomentumScrollEnd={handleMomentumScrollEnd} - onContentSizeChange={handleContentSizeChange} - onLayout={handleListLayout} - scrollEventThrottle={16} - onStartReached={hasOlderMessages ? handleStartReached : undefined} - onStartReachedThreshold={ON_START_REACHED_THRESHOLD} - maintainVisibleContentPosition={{ - // Start rendering from the bottom so the newest message is visible - // on first render. `autoscrollToTopThreshold` is left at its default - // so the viewport only repositions when the user is far enough away - // from the top — preserving the existing auto-follow behavior on - // streaming insertions at the bottom. - startRenderingFromBottom: true, - }} - ListHeaderComponent={ - - } - ListFooterComponent={ListFooterComponent} - keyboardDismissMode="interactive" - keyboardShouldPersistTaps="handled" - /> + + + ref={listRefSafe} + style={listStyle} + contentContainerStyle={listContentContainerStyle} + data={items} + keyExtractor={keyExtractor} + renderItem={renderItem} + onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} + onContentSizeChange={handleContentSizeChange} + onLayout={handleListLayout} + scrollEventThrottle={16} + onStartReached={hasOlderMessages ? handleStartReached : undefined} + onStartReachedThreshold={ON_START_REACHED_THRESHOLD} + maintainVisibleContentPosition={{ + // Start rendering from the bottom so the newest message is visible + // on first render. `autoscrollToTopThreshold` is left at its default + // so the viewport only repositions when the user is far enough away + // from the top — preserving the existing auto-follow behavior on + // streaming insertions at the bottom. + startRenderingFromBottom: true, + }} + ListHeaderComponent={ + + } + ListFooterComponent={ListFooterComponent} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + /> + {/* Floating "scroll to bottom" affordance. Rendered only when the + user has scrolled past the 100px bottom threshold; the fade + animations match the chat-composer convention. `pointerEvents` + is set on the wrapper so empty space around the button keeps + scrolling the list, while the Pressable itself catches taps. */} + {!isAtBottom ? ( + + + + + + ) : null} + ); } diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts index e2044c50ee..d6aff2f039 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + getInitialSessionListAutoScrollVisibility, isSessionListAtBottom, + SESSION_LIST_BOTTOM_THRESHOLD_PX, shouldFollowSessionContentSize, shouldRetrySessionAutoScroll, shouldScheduleSessionAutoScroll, @@ -49,6 +51,30 @@ describe('isSessionListAtBottom', () => { ).toBe(true); }); + it('returns true for any offset when the content is shorter than the viewport', () => { + // The scroll-to-bottom button must never show on short/empty + // conversations: a content height smaller than the viewport height + // means the entire transcript is already visible, so the user is + // at the bottom by definition. Cover both the no-scroll case and + // a non-zero (but still-fully-visible) offset to lock the + // "content < viewport" branch. + expect( + isSessionListAtBottom({ + contentHeight: 200, + viewportHeight: 600, + offsetY: 0, + }) + ).toBe(true); + expect( + isSessionListAtBottom({ + contentHeight: 400, + viewportHeight: 600, + offsetY: 50, + }) + ).toBe(true); + expect(SESSION_LIST_BOTTOM_THRESHOLD_PX).toBe(100); + }); + it('respects a custom threshold for the bottom-stickiness band', () => { expect( isSessionListAtBottom({ @@ -69,6 +95,27 @@ describe('isSessionListAtBottom', () => { }); }); +describe('getInitialSessionListAutoScrollVisibility', () => { + it('starts at-bottom so the scroll-to-bottom button is hidden on first render', () => { + // A fresh transcript is rendered anchored to the latest message, so + // the floating "scroll to bottom" button must never be visible until + // the user has actually scrolled away. + expect(getInitialSessionListAutoScrollVisibility()).toEqual({ + shouldAutoScroll: true, + isAtBottom: true, + }); + }); + + it('is the reset target used when the session changes', () => { + // The hook's `resetKey` effect calls this helper to restore both the + // auto-follow ref and the at-bottom React state. A new/empty session + // must therefore never open with the button visible. + const reset = getInitialSessionListAutoScrollVisibility(); + expect(reset.isAtBottom).toBe(true); + expect(reset.shouldAutoScroll).toBe(true); + }); +}); + describe('shouldScheduleSessionAutoScroll', () => { it('schedules when the user is at the bottom, not auto-scrolling, and not actively dragging', () => { expect( diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts index 8eb8f7015f..04c2de4678 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts @@ -18,6 +18,20 @@ export function isSessionListAtBottom({ return distanceFromBottom < thresholdPx; } +/** + * Initial (and per-session-reset) visibility state for the + * scroll-to-bottom affordance. The user is considered at the bottom at + * the start of every session — a fresh transcript is rendered anchored + * to the latest message, so the floating "scroll to bottom" button + * must never be visible until the user has actually scrolled away. + */ +export function getInitialSessionListAutoScrollVisibility(): { + shouldAutoScroll: boolean; + isAtBottom: boolean; +} { + return { shouldAutoScroll: true, isAtBottom: true }; +} + /** * Decide whether a programmatic scroll-to-latest should be scheduled. * diff --git a/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts index c2486e708a..6873025395 100644 --- a/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts +++ b/apps/mobile/src/components/agents/use-session-list-auto-scroll.ts @@ -1,8 +1,9 @@ import { type FlashListRef } from '@shopify/flash-list'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; import { + getInitialSessionListAutoScrollVisibility, isSessionListAtBottom, SESSION_LIST_BOTTOM_THRESHOLD_PX, shouldFollowSessionContentSize, @@ -28,6 +29,14 @@ export function useSessionListAutoScroll({ }: UseSessionListAutoScrollParams) { const listRef = useRef>(null); const shouldAutoScrollRef = useRef(true); + // React state mirror of `shouldAutoScrollRef`'s at-bottom side, so the + // scroll-to-bottom button can be conditionally rendered. Updated through + // `setIsAtBottom` which is change-gated (functional update returning the + // previous value when unchanged) to avoid re-render churn on every + // scroll frame. + const [isAtBottom, setIsAtBottom] = useState( + getInitialSessionListAutoScrollVisibility().isAtBottom + ); const isAutoScrollingRef = useRef(false); // Tracks whether the user is currently dragging or the list is still in a // momentum fling. While this is true we must not programmatically scroll — @@ -75,6 +84,16 @@ export function useSessionListAutoScroll({ }, 150); }, [clearAutoScrollResetTimeout]); + // Animated scroll for the user-initiated "scroll to bottom" button. + // Intentionally does NOT set `isAutoScrollingRef`: that flag suppresses + // `onScroll` updates inside `handleScroll`, and we *want* the scroll + // events produced by this animation to flow through + // `updateAutoScrollFromEvent` so the at-bottom detection flips and the + // button fades out on completion. + const scrollToLatestAnimated = useCallback(() => { + listRef.current?.scrollToEnd({ animated: true }); + }, []); + const scheduleScrollToLatestMessage = useCallback(() => { if ( !shouldScheduleSessionAutoScroll({ @@ -106,8 +125,10 @@ export function useSessionListAutoScroll({ }, [clearAutoScrollRetryTimeout, scrollToLatestMessage]); useEffect(() => { - shouldAutoScrollRef.current = true; + const initial = getInitialSessionListAutoScrollVisibility(); + shouldAutoScrollRef.current = initial.shouldAutoScroll; lastContentHeightRef.current = 0; + setIsAtBottom(prev => (prev === initial.isAtBottom ? prev : initial.isAtBottom)); }, [resetKey]); useEffect(() => { @@ -128,12 +149,17 @@ export function useSessionListAutoScroll({ const updateAutoScrollFromEvent = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - shouldAutoScrollRef.current = isSessionListAtBottom({ + const nextIsAtBottom = isSessionListAtBottom({ contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height, offsetY: contentOffset.y, thresholdPx: SESSION_LIST_BOTTOM_THRESHOLD_PX, }); + shouldAutoScrollRef.current = nextIsAtBottom; + // Change-gate the setState: returning the previous value when + // unchanged is a React bail-out and avoids a re-render per scroll + // frame while the user holds a steady position. + setIsAtBottom(prev => (prev === nextIsAtBottom ? prev : nextIsAtBottom)); }, [] ); @@ -225,7 +251,9 @@ export function useSessionListAutoScroll({ }, [scheduleScrollToLatestMessage]); return { + isAtBottom, listRef, + scrollToLatestAnimated, handleContentSizeChange, handleListLayout, handleScroll, From 588d193fe1abcbe1395642cdc788c4eaefb87449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 23:29:58 +0200 Subject: [PATCH 3/4] feat(mobile): add clear button to the sessions search input Extract the debounce timer and clear-path semantics of the Agents search into a pure session-search-state module, and add an in-field X clear button that resets only the search query (leaving platform and project narrowing filters untouched). The broader empty-state clear CTAs keep resetting both query and filters, and now also reset the X's visibility flag so it never lingers over an empty field. Also extracts the list's empty-state JSX into session-list-empty-states.tsx to keep session-list-content.tsx under the repository's max-lines limit; this is a behavior-preserving extraction with no visual or logic changes. --- .../agents/session-list-content.tsx | 144 ++++++------- .../agents/session-list-empty-states.tsx | 86 ++++++++ .../components/agents/session-list-screen.tsx | 61 +++--- .../agents/session-search-state.test.ts | 191 ++++++++++++++++++ .../components/agents/session-search-state.ts | 108 ++++++++++ 5 files changed, 495 insertions(+), 95 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-list-empty-states.tsx create mode 100644 apps/mobile/src/components/agents/session-search-state.test.ts create mode 100644 apps/mobile/src/components/agents/session-search-state.ts diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 076cd6ea61..b2ee3fc31b 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,8 +1,9 @@ -import { Bot, Plus, Search, SearchX } from 'lucide-react-native'; +import { Search, X } from 'lucide-react-native'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Platform, + Pressable, RefreshControl, SectionList, TextInput, @@ -13,10 +14,12 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type SessionItem, type SessionSection } from '@/components/agents/session-list-helpers'; +import { + AgentSessionFilteredEmptyState, + AgentSessionListEmptyState, +} from '@/components/agents/session-list-empty-states'; import { RemoteSessionRow, StoredSessionRow } from '@/components/agents/session-row'; -import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; -import { Button } from '@/components/ui/button'; import { Eyebrow } from '@/components/ui/eyebrow'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -45,6 +48,13 @@ type AgentSessionListContentProps = { hasActiveQuery: boolean; isSearching: boolean; onClearQuery: () => void; + /** + * Narrow clear path used by the in-field X: resets the debounced + * search query (and the local `hasText` flag) WITHOUT touching the + * persisted platform/project narrowing filters. The broad + * `onClearQuery` path keeps owning that. + */ + onClearSearchOnly: () => void; onCreateSession: () => void; sortBy: AgentSessionSortBy; }; @@ -65,6 +75,7 @@ export function AgentSessionListContent({ hasActiveQuery, isSearching, onClearQuery, + onClearSearchOnly, onCreateSession, sortBy, }: Readonly) { @@ -74,14 +85,44 @@ export function AgentSessionListContent({ const { deleteSession, renameSession } = useSessionMutations(); const [refreshing, setRefreshing] = useState(false); const searchInputRef = useRef(null); + // Tracks whether the uncontrolled TextInput currently has visible + // text — the iOS search-field X is only rendered while this is true. + // Derived locally from `onChangeText`; the TextInput itself stays + // uncontrolled per the iOS TextInput rule (controlled `value` races + // with native keystrokes on iOS). + const [hasText, setHasText] = useState(false); // The search TextInput is uncontrolled (see iOS TextInput rules — controlled // `value` causes keystroke races), so clearing the query in state alone // wouldn't clear what's visibly typed. Imperatively clear the input too. + // + // Broad path used by the empty-state "Clear search" / "Clear filters" + // CTAs: the screen's `onClearQuery` ALSO resets the persisted + // platform/project narrowing filters, which is why the X has its own + // narrower handler below. const handleClearQuery = useCallback(() => { searchInputRef.current?.clear(); + setHasText(false); onClearQuery(); }, [onClearQuery]); + + // Narrow path used by the in-field X: clears the input, dismisses + // the keyboard, and resets the local `hasText` flag so the X + // disappears — all without touching the persisted narrowing filters. + const handleClearSearchOnly = useCallback(() => { + searchInputRef.current?.clear(); + searchInputRef.current?.blur(); + setHasText(false); + onClearSearchOnly(); + }, [onClearSearchOnly]); + + const handleSearchInputChange = useCallback( + (text: string) => { + setHasText(text.length > 0); + onSearchChange(text); + }, + [onSearchChange] + ); // The tab bar is an absolutely-positioned overlay, so scrollable content // must clear it or the last rows are stuck underneath it. const tabBarClearanceStyle = useMemo( @@ -103,11 +144,22 @@ export function AgentSessionListContent({ className="min-h-6 flex-1 py-1 text-[15px] leading-6 text-foreground" placeholder="Search sessions..." placeholderTextColor={colors.mutedForeground} - onChangeText={onSearchChange} + onChangeText={handleSearchInputChange} returnKeyType="search" autoCapitalize="none" autoCorrect={false} /> + {hasText ? ( + + + + ) : null} {isSearchPending ? ( @@ -124,64 +176,14 @@ export function AgentSessionListContent({ ) : null} ), - [colors.mutedForeground, showInlineError, isSearchPending, onSearchChange] - ); - - const emptyStateAction = useMemo( - () => ( - - ), - [colors.foreground, onCreateSession] - ); - - const clearQueryAction = useMemo( - () => ( - - ), - [handleClearQuery, isSearching] - ); - - // Only reachable when `hasAnySessions` is true (the true first-use empty - // state is handled separately below), which means an active search or - // filter narrowed the results to zero matches — never show the "create a - // task" CTA here, it's not the fix for a filter that's too narrow. - const filteredEmptyComponent = useMemo( - () => ( - - - - ), - [clearQueryAction, isSearching] - ); - - // The query in error produced no rows to show — surface a retry for it - // (search or list, whichever `onRetry` targets) instead of pretending the - // empty result is a real "no matches". - const queryErrorEmptyComponent = useMemo( - () => ( - - - {clearQueryAction} - - ), - [clearQueryAction, isSearching, onRetry] + [ + colors.mutedForeground, + handleClearSearchOnly, + handleSearchInputChange, + hasText, + isSearchPending, + showInlineError, + ] ); const organizationIdBySessionId = useMemo( @@ -283,22 +285,24 @@ export function AgentSessionListContent({ return ( - + ); } let emptyComponent = null; if (hasActiveQuery) { - emptyComponent = isError ? queryErrorEmptyComponent : filteredEmptyComponent; + emptyComponent = ( + + ); } return ( diff --git a/apps/mobile/src/components/agents/session-list-empty-states.tsx b/apps/mobile/src/components/agents/session-list-empty-states.tsx new file mode 100644 index 0000000000..24f0a54342 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-empty-states.tsx @@ -0,0 +1,86 @@ +import { Bot, Plus, SearchX } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +/** + * Shown when the user has no sessions at all (first use). Split out of + * `session-list-content.tsx` purely to keep that file under the repo's + * max-lines limit; it has no state of its own. + */ +export function AgentSessionListEmptyState({ + onCreateSession, +}: Readonly<{ onCreateSession: () => void }>) { + const colors = useThemeColors(); + return ( + + + + New coding task + + } + /> + + ); +} + +/** + * Shown when an active search or filter narrowed the results to zero + * matches (`filtered`), or when the search/list query itself errored + * while there were no cached rows to fall back on (`queryError`). Only + * reachable when `hasAnySessions` is true — the true first-use empty + * state is `AgentSessionListEmptyState` above. + */ +export function AgentSessionFilteredEmptyState({ + variant, + isSearching, + onClearQuery, + onRetry, +}: Readonly<{ + variant: 'filtered' | 'queryError'; + isSearching: boolean; + onClearQuery: () => void; + onRetry: () => void; +}>) { + const clearAction = ( + + ); + + if (variant === 'queryError') { + return ( + + + {clearAction} + + ); + } + + return ( + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 55cf54becc..3195b7cff0 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -5,6 +5,11 @@ import Animated, { LinearTransition } from 'react-native-reanimated'; import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { AgentSessionListContent } from '@/components/agents/session-list-content'; import { SessionListHeaderActions } from '@/components/agents/session-list-header-actions'; +import { + createDefaultSearchTimer, + createSessionSearchController, + type SessionSearchController, +} from '@/components/agents/session-search-state'; import { type ProjectFilterOption, SessionFilterChips, @@ -19,7 +24,6 @@ import { type StoredSessionItem, } from '@/components/agents/session-list-helpers'; import { ScreenHeader } from '@/components/screen-header'; -import { clearAgentSessionNarrowingFilters } from '@/lib/agent-session-filters'; import { useAgentSessions, useAgentSessionSearch, @@ -45,32 +49,38 @@ export function AgentSessionListScreen() { } = usePersistedAgentSessionFilters(); const [showFilterModal, setShowFilterModal] = useState(false); - const searchTimerRef = useRef>(undefined); const [searchQuery, setSearchQuery] = useState(''); - const handleSearchChange = useCallback((text: string) => { - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - searchTimerRef.current = setTimeout(() => { - setSearchQuery(text.trim()); - }, 300); - }, []); - - const handleClearSearch = useCallback(() => { - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - setSearchQuery(''); - }, []); + // Search debounce + clear semantics live in a pure controller so the + // 300ms timing and the two clear paths (search-only vs. broad) can be + // unit tested without react-native or real timers. The controller + // holds its own pending-handle state — no setTimeout leaks into React. + const searchControllerRef = useRef(null); + searchControllerRef.current ??= createSessionSearchController({ + timer: createDefaultSearchTimer(), + commitSearchQuery: setSearchQuery, + }); + const searchController = searchControllerRef.current; + + const handleSearchChange = useCallback( + (text: string) => { + searchController.scheduleSearch(text); + }, + [searchController] + ); + + // Search-only clear used by the in-field X: resets the debounced + // query without touching the persisted platform/project narrowing + // filters — the broad empty-state clear still owns that. + const handleClearSearchOnly = useCallback(() => { + searchController.clearSearchOnly(); + }, [searchController]); useEffect( () => () => { - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } + searchController.dispose(); }, - [] + [searchController] ); const createdOnPlatform = useMemo( @@ -264,11 +274,11 @@ export function AgentSessionListScreen() { const hasAnySessions = storedSessions.length > 0 || activeSessions.length > 0; const handleClearQuery = useCallback(() => { - handleClearSearch(); // Functional update so the persisted sort preference is preserved - // across "Clear search" / "Clear filters". - setFilters(prev => clearAgentSessionNarrowingFilters(prev)); - }, [handleClearSearch, setFilters]); + // across "Clear search" / "Clear filters" — the controller's + // clearBroadly is the single source of truth for the reset pair. + searchController.clearBroadly(setFilters); + }, [searchController, setFilters]); return ( @@ -320,6 +330,7 @@ export function AgentSessionListScreen() { hasActiveQuery={isSearching || hasActiveFilter} isSearching={isSearching} onClearQuery={handleClearQuery} + onClearSearchOnly={handleClearSearchOnly} onCreateSession={() => { router.push(getNewAgentSessionPath(organizationId) as Href); }} diff --git a/apps/mobile/src/components/agents/session-search-state.test.ts b/apps/mobile/src/components/agents/session-search-state.test.ts new file mode 100644 index 0000000000..ee26295230 --- /dev/null +++ b/apps/mobile/src/components/agents/session-search-state.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { + createSessionSearchController, + SEARCH_DEBOUNCE_MS, + type SearchTimer, + type SessionSearchController, +} from '@/components/agents/session-search-state'; +import { createDefaultAgentSessionFilters } from '@/lib/agent-session-filters'; + +type FakeTimer = SearchTimer & { + fire(): void; + hasActive(): boolean; + lastDelay(): number; +}; + +/** + * Single-pending fake timer: mirrors the controller's "one outstanding + * debounce at a time" semantics. Each `set` returns a handle whose + * `cancel()` is independent, and `fire()` runs the current callback + * only if it has not been cancelled. + */ +function createFakeTimer(): FakeTimer { + let current: { id: number; callback: () => void; delay: number } | null = null; + const cancelled = new Set(); + let nextId = 0; + return { + set(callback, delay) { + nextId += 1; + const id = nextId; + current = { id, callback, delay }; + return { + cancel() { + cancelled.add(id); + }, + }; + }, + clear() { + if (current) { + cancelled.add(current.id); + current = null; + } + }, + fire() { + if (current && !cancelled.has(current.id)) { + const snapshot = current; + current = null; + snapshot.callback(); + } else if (current) { + current = null; + } + }, + hasActive: () => current !== null && !cancelled.has(current.id), + lastDelay: () => current?.delay ?? -1, + }; +} + +function createController(timer: FakeTimer, commits: string[]): SessionSearchController { + return createSessionSearchController({ + timer, + commitSearchQuery: query => { + commits.push(query); + }, + }); +} + +describe('createSessionSearchController', () => { + it('schedules a debounced commit of the trimmed text and fires it after the configured delay', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch(' hello world '); + + // No commit yet — the debounce is still pending. + expect(commits).toEqual([]); + expect(controller.hasPending()).toBe(true); + // The configured delay is forwarded to the timer verbatim so the + // existing 300ms UX is preserved by the pure module. + expect(timer.lastDelay()).toBe(SEARCH_DEBOUNCE_MS); + + timer.fire(); + expect(commits).toEqual(['hello world']); + expect(controller.hasPending()).toBe(false); + }); + + it('replaces a pending debounce when a new keystroke arrives so only the latest text is committed', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch('first'); + controller.scheduleSearch('second'); + expect(timer.hasActive()).toBe(true); + + timer.fire(); + expect(commits).toEqual(['second']); + }); + + it('clearSearchOnly cancels the pending debounce, commits an empty query, and never commits the cancelled text', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch('in progress'); + expect(controller.hasPending()).toBe(true); + + controller.clearSearchOnly(); + expect(commits).toEqual(['']); + expect(controller.hasPending()).toBe(false); + + // A late fire from the (already cancelled) timer must NOT land a + // stale commit — that's the whole point of cancelling. + timer.fire(); + expect(commits).toEqual(['']); + }); + + it('clearBroadly commits an empty query AND applies the narrowing-filter reset (preserving sort)', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch('still pending'); + const before = createDefaultAgentSessionFilters(); + const current = { + ...before, + platformFilter: ['macos'], + projectFilter: ['git/a'], + sortBy: 'updated_at' as const, + }; + + let receivedPrev: typeof current | null = null; + controller.clearBroadly(apply => { + receivedPrev = current; + const next = apply(current); + // sortBy stays — it's a persistent preference, not a filter. + expect(next).toEqual({ + platformFilter: [], + projectFilter: [], + sortBy: 'updated_at', + }); + }); + + expect(receivedPrev).toBe(current); + expect(commits).toEqual(['']); + expect(controller.hasPending()).toBe(false); + }); + + it('clearBroadly is safe to call when no debounce is pending and still runs the filter transform', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + let applyCalls = 0; + controller.clearBroadly(apply => { + applyCalls += 1; + // The transform is what actually clears the narrowing filters; + // the test just proves the controller hands the previous value + // through unchanged. + const prev = createDefaultAgentSessionFilters(); + expect(apply(prev)).toEqual(prev); + }); + expect(commits).toEqual(['']); + expect(applyCalls).toBe(1); + }); + + it('dispose cancels a pending debounce without committing', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch('unsent'); + controller.dispose(); + expect(controller.hasPending()).toBe(false); + + timer.fire(); + expect(commits).toEqual([]); + }); + + it('clearSearchOnly followed by a new scheduleSearch commits the new text, not an empty string', () => { + const timer = createFakeTimer(); + const commits: string[] = []; + const controller = createController(timer, commits); + + controller.scheduleSearch('alpha'); + controller.clearSearchOnly(); + controller.scheduleSearch('beta'); + timer.fire(); + expect(commits).toEqual(['', 'beta']); + }); +}); diff --git a/apps/mobile/src/components/agents/session-search-state.ts b/apps/mobile/src/components/agents/session-search-state.ts new file mode 100644 index 0000000000..1f66d28865 --- /dev/null +++ b/apps/mobile/src/components/agents/session-search-state.ts @@ -0,0 +1,108 @@ +// Pure (react/react-native-free) controller for the Agents-page search +// input's debounced commit + clear transitions. Extracted out of +// session-list-screen.tsx so the timing + reset semantics can be unit +// tested with an injectable timer and no react-native test renderer. + +import { + type AgentSessionFilters, + clearAgentSessionNarrowingFilters, +} from '@/lib/agent-session-filters'; + +/** Debounce delay (ms) for trimming + committing the search query. */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** + * Minimal timer contract the search controller needs. Kept narrow so a + * vitest fake can stand in without touching real setTimeout globals. + */ +export type SearchTimer = { + set(callback: () => void, delayMs: number): { cancel(): void }; + clear(): void; +}; + +/** Real-browser-style timer, used by the screen's wiring. */ +export function createDefaultSearchTimer(): SearchTimer { + return { + set(callback, delayMs) { + const handle = setTimeout(callback, delayMs); + return { + cancel: () => { + clearTimeout(handle); + }, + }; + }, + clear() { + // Per-handle `cancel()` is the supported path; this stays for API + // completeness and as a no-op when nothing is pending. + }, + }; +} + +export type SessionSearchController = { + /** + * Schedule a debounced commit of the (trimmed) text, replacing any + * pending one. Mirrors the prior in-screen behavior: a fast typist + * never sees a stale commit land behind their latest keystroke. + */ + scheduleSearch(text: string): void; + /** + * Search-only clear: cancel any pending debounce and commit an empty + * query. Intentionally does NOT touch the persisted platform/project + * narrowing filters — that is the in-field X's job, not the broad + * "Clear filters" CTA's. + */ + clearSearchOnly(): void; + /** + * Broad clear (empty-state "Clear search" / "Clear filters" CTAs): + * cancel any pending debounce, commit an empty query, AND apply the + * narrowing-filter reset via the provided `applyFilters` callback so + * the screen can preserve the persisted sort preference. + */ + clearBroadly( + applyFilters: (transform: (prev: AgentSessionFilters) => AgentSessionFilters) => void + ): void; + /** Cancel any pending debounce without committing (used on unmount). */ + dispose(): void; + /** Test-only: whether a debounce is currently pending. */ + hasPending(): boolean; +}; + +export function createSessionSearchController({ + timer, + delayMs = SEARCH_DEBOUNCE_MS, + commitSearchQuery, +}: { + timer: SearchTimer; + delayMs?: number; + commitSearchQuery: (query: string) => void; +}): SessionSearchController { + let pending: { cancel(): void } | null = null; + + function cancelPending(): void { + if (pending) { + pending.cancel(); + pending = null; + } + } + + return { + scheduleSearch(text) { + cancelPending(); + pending = timer.set(() => { + pending = null; + commitSearchQuery(text.trim()); + }, delayMs); + }, + clearSearchOnly() { + cancelPending(); + commitSearchQuery(''); + }, + clearBroadly(applyFilters) { + cancelPending(); + commitSearchQuery(''); + applyFilters(prev => clearAgentSessionNarrowingFilters(prev)); + }, + dispose: cancelPending, + hasPending: () => pending !== null, + }; +} From 89b0e5ccf81df316a22db252c34a4e6ca0fd4106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 23:30:09 +0200 Subject: [PATCH 4/4] fix(mobile): unify composer mic button with the input row Give VoiceInputButton a coupled size/hitSlop appearance prop so the mic matches its sibling controls in the agent chat composer (h-8, hitSlop 6) and the new-session prompt (h-9, hitSlop 4), while the default (no prop) stays pixel-identical to today's look for the kilo-chat composer, which is untouched. Effective touch target stays >=44pt at every size. --- .../agents/chat-composer-input-row.tsx | 1 + .../components/agents/new-session-prompt.tsx | 1 + .../src/components/voice-input-control.tsx | 46 +++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/components/agents/chat-composer-input-row.tsx b/apps/mobile/src/components/agents/chat-composer-input-row.tsx index f332806667..12ca2255c6 100644 --- a/apps/mobile/src/components/agents/chat-composer-input-row.tsx +++ b/apps/mobile/src/components/agents/chat-composer-input-row.tsx @@ -129,6 +129,7 @@ export function ChatComposerInputRow({ diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx index 947e336815..fcc666ce3a 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -209,6 +209,7 @@ export function NewSessionPrompt({ diff --git a/apps/mobile/src/components/voice-input-control.tsx b/apps/mobile/src/components/voice-input-control.tsx index 620f26795b..636dcb85de 100644 --- a/apps/mobile/src/components/voice-input-control.tsx +++ b/apps/mobile/src/components/voice-input-control.tsx @@ -6,27 +6,55 @@ import { cn } from '@/lib/utils'; import { type VoiceInputStatus } from '@/lib/voice-input/voice-input-state'; import { resolveVoiceInputControlState } from '@/lib/voice-input/voice-input-view-state'; +type VoiceInputButtonSize = 'sm' | 'md'; + type VoiceInputButtonProps = { disabled: boolean; onPress(): void; status: VoiceInputStatus; + size?: VoiceInputButtonSize; }; +// Visual class and hitSlop travel as a coupled pair so the effective touch +// target stays >=44pt (visual size + 2 * hitSlop per side) at every size. +const SIZE_STYLES: Record< + VoiceInputButtonSize, + { + className: string; + hitSlop: { top: number; bottom: number; left: number; right: number }; + } +> = { + sm: { + className: 'h-8 w-8 rounded-full', + hitSlop: { top: 6, bottom: 6, left: 6, right: 6 }, + }, + md: { + className: 'h-9 w-9 rounded-full', + hitSlop: { top: 4, bottom: 4, left: 4, right: 4 }, + }, +}; + +// Default (no prop) preserves the original kilo-chat look. +const DEFAULT_STYLE = { + className: 'h-10 w-10 rounded-md', + hitSlop: { top: 2, bottom: 2, left: 2, right: 2 }, +} as const; + const ICON_SIZE = 18; -const HIT_SLOP = { top: 2, bottom: 2, left: 2, right: 2 } as const; const LISTENING_BG = 'bg-red-600 dark:bg-red-500'; const RESTING_BG = 'bg-secondary'; /** - * Compact voice-input toggle for composer toolbars. Renders a 10x10 visual - * square that expands to a 44pt touch target via `hitSlop`. While the - * controller is in `starting` or `stopping`, an ActivityIndicator replaces - * the icon and the press is ignored. While `listening`, a destructive - * treatment signals the active session. + * Compact voice-input toggle for composer toolbars. Visual size is controlled + * by `size`; the default renders a 10x10 square that expands to a 44pt touch + * target via `hitSlop`. While the controller is in `starting` or `stopping`, + * an ActivityIndicator replaces the icon and the press is ignored. While + * `listening`, a destructive treatment signals the active session. */ export function VoiceInputButton({ disabled, onPress, + size, status, }: Readonly): React.ReactElement { const colors = useThemeColors(); @@ -35,8 +63,10 @@ export function VoiceInputButton({ const showSpinner = control.busy; const iconColor = isListeningOrStopping ? colors.destructiveForeground : colors.foreground; const restingBg = isListeningOrStopping ? LISTENING_BG : RESTING_BG; + const sizeStyle = size ? SIZE_STYLES[size] : DEFAULT_STYLE; const containerClass = cn( - 'h-10 w-10 items-center justify-center rounded-md active:opacity-70', + sizeStyle.className, + 'items-center justify-center active:opacity-70', restingBg ); const renderIcon = (): React.ReactElement => { @@ -56,7 +86,7 @@ export function VoiceInputButton({ accessibilityState={{ busy: control.busy, disabled: control.disabled }} className={containerClass} disabled={control.disabled} - hitSlop={HIT_SLOP} + hitSlop={sizeStyle.hitSlop} onPress={onPress} > {renderIcon()}