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}
/>