diff --git a/__tests__/integration/components/TerminalOutput.test.tsx b/__tests__/integration/components/TerminalOutput.test.tsx index 9f2edf6c..284246f1 100644 --- a/__tests__/integration/components/TerminalOutput.test.tsx +++ b/__tests__/integration/components/TerminalOutput.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { StyleSheet } from 'react-native' -import { render } from '@testing-library/react-native' +import { fireEvent, render } from '@testing-library/react-native' import { TerminalOutput } from '@/components/terminal/TerminalOutput' describe('TerminalOutput – rendering', () => { @@ -153,3 +153,39 @@ describe('TerminalOutput – row testIDs', () => { expect(queryAllByTestId('terminal-line-row')).toHaveLength(2) }) }) + +describe('TerminalOutput – resumed scrollback notice', () => { + it('does not render the notice without onViewResumedConversation', async () => { + const { queryByTestId } = await render( + + ) + expect(queryByTestId('terminal-resumed-scrollback-notice')).toBeNull() + }) + + it('renders the notice at the top of scrollback when the callback is set', async () => { + const onView = 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() + }) + + it('invokes onViewResumedConversation when the notice is pressed', async () => { + const onView = jest.fn() + const { getByTestId } = await render( + + ) + await fireEvent.press(getByTestId('terminal-resumed-scrollback-notice')) + expect(onView).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/integration/components/TerminalView.test.tsx b/__tests__/integration/components/TerminalView.test.tsx index eb3c0401..85bda87a 100644 --- a/__tests__/integration/components/TerminalView.test.tsx +++ b/__tests__/integration/components/TerminalView.test.tsx @@ -29,6 +29,12 @@ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), })) +const mockPush = jest.fn() +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), + useLocalSearchParams: () => ({}), +})) + // ── feature mocks ──────────────────────────────────────────────────────────── const mockSendInputMutate = jest.fn() const mockSendKeysMutate = jest.fn() @@ -93,9 +99,9 @@ jest.mock('@/components/queue/PlanPreviewSheet', () => ({ // eslint-disable-next-line import/first import { TerminalView } from '@/components/terminal/TerminalView' -async function renderView() { +async function renderView(props?: { resumedConversationId?: string }) { return await render( - , + , { wrapper: createWrapper() }, ) } @@ -104,6 +110,7 @@ describe('TerminalView', () => { beforeEach(() => { mockSendInputMutate.mockClear() mockSendKeysMutate.mockClear() + mockPush.mockClear() }) it('renders terminal-line-row elements when lines are provided', async () => { @@ -122,4 +129,17 @@ describe('TerminalView', () => { await fireEvent.press(screen.getByTestId('chat-send-button')) expect(mockSendInputMutate).toHaveBeenCalledWith('test-payload', expect.anything()) }) + + it('does not show the resumed scrollback notice for a fresh session', async () => { + await renderView() + expect(screen.queryByTestId('terminal-resumed-scrollback-notice')).toBeNull() + }) + + 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') + expect(notice).toBeTruthy() + await fireEvent.press(notice) + expect(mockPush).toHaveBeenCalledWith('/conversation/conv-42?server=srv1') + }) }) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 4002c6ca..0267342f 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -1008,6 +1008,7 @@ export default function SessionDetailScreen() { disabled={isWakingUp} pendingPlan={planVisible ? pendingPlan : null} onClosePlan={() => { setPlanVisible(false); setPendingPlan(null) }} + resumedConversationId={session.resumedFromConversationId} /> ) : ( ) => void + /** + * Resumed sessions start a fresh PTY — prior terminal bytes are gone. + * When set, show a scrollback-top disclosure linking to the durable conversation. + */ + onViewResumedConversation?: () => void } -export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTexts, onSendInput, onSendKeys, activeQuestion, onAnswer }: Props) { +export function TerminalOutput({ + lines, + isStreaming: _isStreaming, + userMessageTexts, + onSendInput, + onSendKeys, + activeQuestion, + onAnswer, + onViewResumedConversation, +}: Props) { const { t } = useTranslation('common') + const { t: tTerminal } = useTranslation('terminal') const collapsedLines = useMemo( () => collapseWrappedUserLines(lines, userMessageTexts), [lines, userMessageTexts], @@ -211,6 +226,22 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe onAnswer?.(activeQuestion.toolUseId, { [q.question]: q.options[optionIndex].label }) }, [activeQuestion, onAnswer, onSendKeys]) + const listHeader = useMemo(() => { + if (!onViewResumedConversation) return null + return ( + + {tTerminal('session.resumedEmptyScrollback')} + {tTerminal('session.resumedEmptyScrollbackLink')} + + ) + }, [onViewResumedConversation, tTerminal]) + return ( void + /** Conversation that was resumed into this session — when set, disclose missing PTY scrollback. */ + resumedConversationId?: string | null } export function TerminalView({ @@ -35,8 +39,10 @@ export function TerminalView({ disabled = false, pendingPlan = null, onClosePlan, + resumedConversationId = null, }: Props) { const { t } = useTranslation('terminal') + const router = useRouter() const { lines, isStreaming, userMessageTexts, parseConfidence } = useTerminalStream( serverId, sessionId, @@ -47,6 +53,11 @@ export function TerminalView({ const { sendInput, sendKeys, respondToQuestion } = useSessionActions(serverId, sessionId) const { question: activeQuestion } = useActiveQuestion(serverId, sessionId) + const onViewResumedConversation = useCallback(() => { + if (!resumedConversationId) return + router.push(conversationHref(resumedConversationId, serverId) as never) + }, [resumedConversationId, router, serverId]) + const onSend = (payload: string) => { markSessionUsed(sessionId) sendInput.mutate(payload, { @@ -91,6 +102,7 @@ export function TerminalView({ onSendKeys={(keys) => sendKeys.mutate(keys)} activeQuestion={activeQuestion} onAnswer={(toolUseId, answers) => respondToQuestion.mutate({ toolUseId, answers })} + onViewResumedConversation={resumedConversationId ? onViewResumedConversation : undefined} />