From b6de0e3dc9bd034fd82d38a5471d4489d51918e7 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Tue, 4 Aug 2026 11:49:03 +0000 Subject: [PATCH] fix(conversation): back to live session from resumed history and split the notice into view and search - When the terminal scrollback notice opens conversation history, pass fromSession so the footer offers "Back to Live Session" instead of Resume. - Replace the single history link with View / search / history-tail actions. Search opens the conversation with the in-chat search bar focused so the keyboard appears immediately. --- .../components/TerminalOutput.test.tsx | 34 ++++++++-- .../components/TerminalView.test.tsx | 22 ++++++- .../conversation-detail-gating.test.tsx | 54 +++++++++++++++- __tests__/unit/lib/conversationHref.test.ts | 30 +++++++++ app/conversation/[id].tsx | 64 ++++++++++++++----- components/terminal/TerminalOutput.tsx | 63 ++++++++++++++++-- components/terminal/TerminalView.tsx | 19 +++++- lib/conversationHref.ts | 22 ++++++- locales/ar/conversation.json | 1 + locales/ar/terminal.json | 6 +- locales/en/conversation.json | 1 + locales/en/terminal.json | 6 +- locales/he/conversation.json | 1 + locales/he/terminal.json | 6 +- locales/ru/conversation.json | 1 + locales/ru/terminal.json | 6 +- 16 files changed, 295 insertions(+), 41 deletions(-) diff --git a/__tests__/integration/components/TerminalOutput.test.tsx b/__tests__/integration/components/TerminalOutput.test.tsx index 284246f1..3278eeb8 100644 --- a/__tests__/integration/components/TerminalOutput.test.tsx +++ b/__tests__/integration/components/TerminalOutput.test.tsx @@ -162,30 +162,54 @@ describe('TerminalOutput – resumed scrollback notice', () => { expect(queryByTestId('terminal-resumed-scrollback-notice')).toBeNull() }) - it('renders the notice at the top of scrollback when the callback is set', async () => { + it('renders the split view/search/history links when the callback is set', async () => { const onView = jest.fn() + const onSearch = jest.fn() const { getByTestId, getByText } = await render( ) expect(getByTestId('terminal-resumed-scrollback-notice')).toBeTruthy() expect(getByText("Earlier output isn't available for a resumed session.")).toBeTruthy() - expect(getByText('View the conversation history →')).toBeTruthy() + expect(getByText('View')).toBeTruthy() + expect(getByText('search')).toBeTruthy() + expect(getByText('the conversation history →')).toBeTruthy() }) - it('invokes onViewResumedConversation when the notice is pressed', async () => { + it('invokes onViewResumedConversation from View and history tail', async () => { const onView = jest.fn() + const onSearch = jest.fn() const { getByTestId } = await render( ) - await fireEvent.press(getByTestId('terminal-resumed-scrollback-notice')) - expect(onView).toHaveBeenCalledTimes(1) + await fireEvent.press(getByTestId('terminal-resumed-history-view')) + await fireEvent.press(getByTestId('terminal-resumed-history-tail')) + expect(onView).toHaveBeenCalledTimes(2) + expect(onSearch).not.toHaveBeenCalled() + }) + + it('invokes onSearchResumedConversation from search', async () => { + const onView = jest.fn() + const onSearch = jest.fn() + const { getByTestId } = await render( + + ) + await fireEvent.press(getByTestId('terminal-resumed-history-search')) + expect(onSearch).toHaveBeenCalledTimes(1) + expect(onView).not.toHaveBeenCalled() }) }) diff --git a/__tests__/integration/components/TerminalView.test.tsx b/__tests__/integration/components/TerminalView.test.tsx index 85bda87a..10fcb903 100644 --- a/__tests__/integration/components/TerminalView.test.tsx +++ b/__tests__/integration/components/TerminalView.test.tsx @@ -137,9 +137,27 @@ describe('TerminalView', () => { it('shows the resumed scrollback notice and navigates to the conversation', async () => { await renderView({ resumedConversationId: 'conv-42' }) - const notice = screen.getByTestId('terminal-resumed-scrollback-notice') + const notice = screen.getByTestId('terminal-resumed-history-view') expect(notice).toBeTruthy() await fireEvent.press(notice) - expect(mockPush).toHaveBeenCalledWith('/conversation/conv-42?server=srv1') + expect(mockPush).toHaveBeenCalledWith( + '/conversation/conv-42?server=srv1&fromSession=sess1', + ) + }) + + it('navigates to conversation search when the search link is pressed', async () => { + await renderView({ resumedConversationId: 'conv-42' }) + await fireEvent.press(screen.getByTestId('terminal-resumed-history-search')) + expect(mockPush).toHaveBeenCalledWith( + '/conversation/conv-42?server=srv1&fromSession=sess1&openSearch=1', + ) + }) + + it('navigates to the conversation when the history tail link is pressed', async () => { + await renderView({ resumedConversationId: 'conv-42' }) + await fireEvent.press(screen.getByTestId('terminal-resumed-history-tail')) + expect(mockPush).toHaveBeenCalledWith( + '/conversation/conv-42?server=srv1&fromSession=sess1', + ) }) }) diff --git a/__tests__/integration/conversation-detail-gating.test.tsx b/__tests__/integration/conversation-detail-gating.test.tsx index beb0930f..13176ca4 100644 --- a/__tests__/integration/conversation-detail-gating.test.tsx +++ b/__tests__/integration/conversation-detail-gating.test.tsx @@ -11,7 +11,7 @@ * the list is still resizing. */ import React from 'react' -import { render, act, type RenderResult } from '@testing-library/react-native' +import { render, act, fireEvent, type RenderResult } from '@testing-library/react-native' import { useLocalSearchParams, useRouter } from 'expo-router' import ConversationDetailScreen from '@/app/conversation/[id]' import { useServersStore } from '@/stores/servers' @@ -259,6 +259,58 @@ describe('conversation detail — resumability gating', () => { expect(text).not.toContain("Can't resume") expect(text).not.toContain('no longer exists') }) + + it('replaces Resume with Back to Live Session when opened from a live session', async () => { + const mockReplace = jest.fn() + ;(useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + navigate: jest.fn(), + canGoBack: jest.fn(() => true), + }) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ + id: 'conv-gating', + server: 'srv1', + fromSession: 'sess-live-1', + }) + mockDetailRef.current = { + ...makeDetail(4), + meta: { ...makeDetail(4).meta, resumable: true }, + } + const root = await render(, { wrapper: createWrapper() }) + await flushQueriesAndLiftSkeleton() + + const text = allText(root) + expect(text).toContain('Back to Live Session') + expect(text).not.toContain('Resume Session') + expect(root.getByTestId('back-to-live-session-button')).toBeTruthy() + expect(root.queryByTestId('resume-button')).toBeNull() + + await act(async () => { + fireEvent.press(root.getByTestId('back-to-live-session-button')) + }) + expect(mockReplace).toHaveBeenCalledWith('/session/sess-live-1?server=srv1') + }) + + it('opens the search bar when navigated with openSearch=1', async () => { + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ + id: 'conv-gating', + server: 'srv1', + fromSession: 'sess-live-1', + openSearch: '1', + }) + mockDetailRef.current = { + ...makeDetail(4), + meta: { ...makeDetail(4).meta, resumable: true }, + } + const root = await render(, { wrapper: createWrapper() }) + await flushQueriesAndLiftSkeleton() + + expect(root.getByTestId('conversation-search-bar')).toBeTruthy() + expect(root.getByTestId('conversation-search-input')).toBeTruthy() + expect(allText(root)).toContain('Back to Live Session') + }) }) describe('conversation detail — 404 live-session fallback', () => { diff --git a/__tests__/unit/lib/conversationHref.test.ts b/__tests__/unit/lib/conversationHref.test.ts index 086dbcfd..f0ba70d4 100644 --- a/__tests__/unit/lib/conversationHref.test.ts +++ b/__tests__/unit/lib/conversationHref.test.ts @@ -24,4 +24,34 @@ describe('conversationHref', () => { '/conversation/c1?server=srv_a&search=a%20%26%20b%20caf%C3%A9', ) }) + + it('appends fromSession when returning from a live session', () => { + expect(conversationHref('c1', 'srv_a', undefined, { fromSession: 'sess-9' })).toBe( + '/conversation/c1?server=srv_a&fromSession=sess-9', + ) + }) + + it('combines search and fromSession', () => { + expect(conversationHref('c1', 'srv_a', 'wombat', { fromSession: 'sess-9' })).toBe( + '/conversation/c1?server=srv_a&search=wombat&fromSession=sess-9', + ) + }) + + it('ignores blank fromSession', () => { + expect(conversationHref('c1', 'srv_a', undefined, { fromSession: ' ' })).toBe( + '/conversation/c1?server=srv_a', + ) + }) + + it('appends openSearch when requested', () => { + expect(conversationHref('c1', 'srv_a', undefined, { openSearch: true })).toBe( + '/conversation/c1?server=srv_a&openSearch=1', + ) + }) + + it('combines fromSession and openSearch', () => { + expect( + conversationHref('c1', 'srv_a', undefined, { fromSession: 'sess-9', openSearch: true }), + ).toBe('/conversation/c1?server=srv_a&fromSession=sess-9&openSearch=1') + }) }) diff --git a/app/conversation/[id].tsx b/app/conversation/[id].tsx index 57780b53..7f7f9852 100644 --- a/app/conversation/[id].tsx +++ b/app/conversation/[id].tsx @@ -17,7 +17,7 @@ import { type ListRenderItemInfo, } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' -import { ExportIcon, InfoIcon, MagnifyingGlass, Play, Star } from 'phosphor-react-native' +import { ExportIcon, InfoIcon, MagnifyingGlass, Play, ArrowLeft, Star } from 'phosphor-react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -70,17 +70,25 @@ export default function ConversationDetailScreen() { const theme = useTheme() const styles = useMemo(() => makeStyles(theme), [theme]) const searchStyles = useMemo(() => makeSearchStyles(theme), [theme]) - const { id, server, search, anchor_index } = useLocalSearchParams<{ + const { id, server, search, anchor_index, fromSession: fromSessionParam, openSearch: openSearchParam } = useLocalSearchParams<{ id: string server?: string search?: string anchor_index?: string + fromSession?: string + openSearch?: string }>() const router = useRouter() // Fall back to first server if no server param provided const fallbackServerId = useServersStore((s) => s.activeServerIds[0] ?? '') const serverId = server || fallbackServerId + const fromSession = + typeof fromSessionParam === 'string' && fromSessionParam.trim().length > 0 + ? fromSessionParam.trim() + : undefined + const openSearchRequested = + openSearchParam === '1' || openSearchParam === 'true' const searchQuery = typeof search === 'string' && search.trim().length > 0 ? search : undefined const anchorParam = typeof anchor_index === 'string' ? Number.parseInt(anchor_index, 10) : NaN @@ -105,19 +113,25 @@ export default function ConversationDetailScreen() { useEffect(() => () => finishOpenTrace('unmount'), [id]) // In-chat search entry: toggles a query bar that writes ?search= on submit. - // Prefills / auto-opens when navigation already carries a search param (Hub). + // Prefills / auto-opens when navigation already carries a search param (Hub) + // or openSearch=1 (resumed-scrollback "search" link — empty bar + keyboard). // Synced during render (same pattern as fetchAnchor below) so we don't need // an effect that setStates on searchQuery changes. const [searchBarState, setSearchBarState] = useState<{ open: boolean draft: string syncedQuery: string | undefined - }>({ open: false, draft: '', syncedQuery: undefined }) - if (searchQuery !== searchBarState.syncedQuery) { + syncedOpenSearch: boolean + }>({ open: false, draft: '', syncedQuery: undefined, syncedOpenSearch: false }) + if ( + searchQuery !== searchBarState.syncedQuery || + openSearchRequested !== searchBarState.syncedOpenSearch + ) { setSearchBarState({ - open: searchQuery ? true : searchBarState.open, + open: searchQuery || openSearchRequested ? true : searchBarState.open, draft: searchQuery ?? '', syncedQuery: searchQuery, + syncedOpenSearch: openSearchRequested, }) } const { open: searchOpen, draft: searchDraft } = searchBarState @@ -533,6 +547,13 @@ export default function ConversationDetailScreen() { attempt() }, [resume, navigateToResumedSession, forceResume, takeOverSession, t]) + const handleBackToLiveSession = useCallback(() => { + if (!fromSession) return + markNavigatedToSession(fromSession) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.replace(`/session/${fromSession}?server=${serverId}` as any) + }, [fromSession, router, serverId]) + const handleShare = useCallback(async () => { if (!conversation) return const md = conversation.messages @@ -742,12 +763,17 @@ export default function ConversationDetailScreen() { ? t('unavailable.worktreeRemoved') : t('unavailable.pathMissing') : null - const canResume = !notResumable && !resume.isPending - const resumeLabel = notResumable - ? t('unavailable.cannotResume') - : resume.isPending - ? t('resume.resuming') - : t('resume.start') + const showBackToLive = Boolean(fromSession) + const canResume = !showBackToLive && !notResumable && !resume.isPending + const resumeLabel = showBackToLive + ? t('resume.backToLive') + : notResumable + ? t('unavailable.cannotResume') + : resume.isPending + ? t('resume.resuming') + : t('resume.start') + const footerActionDisabled = showBackToLive ? false : notResumable || resume.isPending + const footerActionTestId = showBackToLive ? 'back-to-live-session-button' : 'resume-button' const showSearchView = isAnchored && @@ -823,12 +849,16 @@ export default function ConversationDetailScreen() { - {canResume ? : null} + {showBackToLive ? ( + + ) : canResume ? ( + + ) : null} {resumeLabel} diff --git a/components/terminal/TerminalOutput.tsx b/components/terminal/TerminalOutput.tsx index b946567e..3c1f53e7 100644 --- a/components/terminal/TerminalOutput.tsx +++ b/components/terminal/TerminalOutput.tsx @@ -117,6 +117,8 @@ interface Props { * When set, show a scrollback-top disclosure linking to the durable conversation. */ onViewResumedConversation?: () => void + /** Same destination as view, but opens in-chat search with the keyboard up. */ + onSearchResumedConversation?: () => void } export function TerminalOutput({ @@ -128,6 +130,7 @@ export function TerminalOutput({ activeQuestion, onAnswer, onViewResumedConversation, + onSearchResumedConversation, }: Props) { const { t } = useTranslation('common') const { t: tTerminal } = useTranslation('terminal') @@ -228,19 +231,55 @@ export function TerminalOutput({ const listHeader = useMemo(() => { if (!onViewResumedConversation) return null + const onSearch = onSearchResumedConversation ?? onViewResumedConversation + const linkA11y = [ + tTerminal('session.resumedHistoryLinkView'), + tTerminal('session.resumedHistoryLinkOr').trim(), + tTerminal('session.resumedHistoryLinkSearch'), + tTerminal('session.resumedHistoryLinkIn').trim(), + tTerminal('session.resumedHistoryLinkTail'), + ].join(' ') return ( - {tTerminal('session.resumedEmptyScrollback')} - {tTerminal('session.resumedEmptyScrollbackLink')} - + + + {tTerminal('session.resumedHistoryLinkView')} + + {tTerminal('session.resumedHistoryLinkOr')} + + {tTerminal('session.resumedHistoryLinkSearch')} + + {tTerminal('session.resumedHistoryLinkIn')} + + {tTerminal('session.resumedHistoryLinkTail')} + + + ) - }, [onViewResumedConversation, tTerminal]) + }, [onSearchResumedConversation, onViewResumedConversation, tTerminal]) return ( @@ -325,6 +364,16 @@ const styles = StyleSheet.create({ fontSize: 12, lineHeight: 16, }, + resumedNoticeLinkRow: { + color: '#8b949e', + fontSize: 12, + lineHeight: 16, + }, + resumedNoticePlain: { + color: '#8b949e', + fontSize: 12, + lineHeight: 16, + }, resumedNoticeLink: { color: '#58a6ff', fontSize: 12, diff --git a/components/terminal/TerminalView.tsx b/components/terminal/TerminalView.tsx index 44532b8d..52c58200 100644 --- a/components/terminal/TerminalView.tsx +++ b/components/terminal/TerminalView.tsx @@ -55,8 +55,22 @@ export function TerminalView({ const onViewResumedConversation = useCallback(() => { if (!resumedConversationId) return - router.push(conversationHref(resumedConversationId, serverId) as never) - }, [resumedConversationId, router, serverId]) + router.push( + conversationHref(resumedConversationId, serverId, undefined, { + fromSession: sessionId, + }) as never, + ) + }, [resumedConversationId, router, serverId, sessionId]) + + const onSearchResumedConversation = useCallback(() => { + if (!resumedConversationId) return + router.push( + conversationHref(resumedConversationId, serverId, undefined, { + fromSession: sessionId, + openSearch: true, + }) as never, + ) + }, [resumedConversationId, router, serverId, sessionId]) const onSend = (payload: string) => { markSessionUsed(sessionId) @@ -103,6 +117,7 @@ export function TerminalView({ activeQuestion={activeQuestion} onAnswer={(toolUseId, answers) => respondToQuestion.mutate({ toolUseId, answers })} onViewResumedConversation={resumedConversationId ? onViewResumedConversation : undefined} + onSearchResumedConversation={resumedConversationId ? onSearchResumedConversation : undefined} />