Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions __tests__/integration/components/TerminalOutput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TerminalOutput
lines={['startup']}
isStreaming={false}
onViewResumedConversation={onView}
onSearchResumedConversation={onSearch}
/>
)
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(
<TerminalOutput
lines={[]}
isStreaming={false}
onViewResumedConversation={onView}
onSearchResumedConversation={onSearch}
/>
)
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(
<TerminalOutput
lines={[]}
isStreaming={false}
onViewResumedConversation={onView}
onSearchResumedConversation={onSearch}
/>
)
await fireEvent.press(getByTestId('terminal-resumed-history-search'))
expect(onSearch).toHaveBeenCalledTimes(1)
expect(onView).not.toHaveBeenCalled()
})
})
22 changes: 20 additions & 2 deletions __tests__/integration/components/TerminalView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
})
54 changes: 53 additions & 1 deletion __tests__/integration/conversation-detail-gating.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(<ConversationDetailScreen />, { 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(<ConversationDetailScreen />, { 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', () => {
Expand Down
30 changes: 30 additions & 0 deletions __tests__/unit/lib/conversationHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
64 changes: 47 additions & 17 deletions app/conversation/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -823,12 +849,16 @@ export default function ConversationDetailScreen() {
<View style={styles.footer} onLayout={handleFooterLayout} testID="conversation-bottom-bar">
<View style={styles.resumeWrapper}>
<TouchableOpacity
style={[styles.resumeBtn, (notResumable || resume.isPending) && styles.resumeBtnDisabled]}
onPress={handleResume}
disabled={notResumable || resume.isPending}
testID="resume-button"
style={[styles.resumeBtn, footerActionDisabled && styles.resumeBtnDisabled]}
onPress={showBackToLive ? handleBackToLiveSession : handleResume}
disabled={footerActionDisabled}
testID={footerActionTestId}
>
{canResume ? <Play size={16} weight="fill" color="#fff" /> : null}
{showBackToLive ? (
<ArrowLeft size={16} weight="bold" color="#fff" />
) : canResume ? (
<Play size={16} weight="fill" color="#fff" />
) : null}
<Text style={styles.resumeBtnText}>{resumeLabel}</Text>
</TouchableOpacity>
</View>
Expand Down
Loading
Loading