diff --git a/__tests__/integration/components/TerminalView.test.tsx b/__tests__/integration/components/TerminalView.test.tsx index 85bda87a..499beda1 100644 --- a/__tests__/integration/components/TerminalView.test.tsx +++ b/__tests__/integration/components/TerminalView.test.tsx @@ -140,6 +140,8 @@ describe('TerminalView', () => { const notice = screen.getByTestId('terminal-resumed-scrollback-notice') 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', + ) }) }) diff --git a/__tests__/integration/conversation-detail-gating.test.tsx b/__tests__/integration/conversation-detail-gating.test.tsx index beb0930f..fac12775 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,39 @@ 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') + }) }) 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..3f39ba65 100644 --- a/__tests__/unit/lib/conversationHref.test.ts +++ b/__tests__/unit/lib/conversationHref.test.ts @@ -24,4 +24,22 @@ 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', + ) + }) }) diff --git a/app/conversation/[id].tsx b/app/conversation/[id].tsx index 57780b53..d3214fdf 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,22 @@ 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 } = useLocalSearchParams<{ id: string server?: string search?: string anchor_index?: string + fromSession?: 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 searchQuery = typeof search === 'string' && search.trim().length > 0 ? search : undefined const anchorParam = typeof anchor_index === 'string' ? Number.parseInt(anchor_index, 10) : NaN @@ -533,6 +538,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 +754,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 +840,16 @@ export default function ConversationDetailScreen() { - {canResume ? : null} + {showBackToLive ? ( + + ) : canResume ? ( + + ) : null} {resumeLabel} diff --git a/components/terminal/TerminalView.tsx b/components/terminal/TerminalView.tsx index 44532b8d..724244f5 100644 --- a/components/terminal/TerminalView.tsx +++ b/components/terminal/TerminalView.tsx @@ -55,8 +55,12 @@ 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 onSend = (payload: string) => { markSessionUsed(sessionId) diff --git a/lib/conversationHref.ts b/lib/conversationHref.ts index ae28f047..5bd15f79 100644 --- a/lib/conversationHref.ts +++ b/lib/conversationHref.ts @@ -3,9 +3,21 @@ * appended so the detail screen can resolve an anchored, highlighted window — * an empty/whitespace-only search produces the exact URL non-search navigation * already uses. + * + * `fromSession` marks navigation from a live session (e.g. the resumed-scrollback + * notice) so the conversation footer can offer "Back to Live Session" instead of + * Resume. */ -export function conversationHref(id: string, serverId: string, search?: string): string { - const base = `/conversation/${id}?server=${serverId}` +export function conversationHref( + id: string, + serverId: string, + search?: string, + opts?: { fromSession?: string }, +): string { + let href = `/conversation/${id}?server=${serverId}` const q = search?.trim() - return q ? `${base}&search=${encodeURIComponent(q)}` : base + if (q) href += `&search=${encodeURIComponent(q)}` + const fromSession = opts?.fromSession?.trim() + if (fromSession) href += `&fromSession=${encodeURIComponent(fromSession)}` + return href } diff --git a/locales/ar/conversation.json b/locales/ar/conversation.json index 3d8bc2a9..112b8b3a 100644 --- a/locales/ar/conversation.json +++ b/locales/ar/conversation.json @@ -19,6 +19,7 @@ }, "resume": { "start": "استئناف السيشن", + "backToLive": "العودة إلى الجلسة المباشرة", "resuming": "جارٍ الاستئناف…", "collisionTitle": "استئناف هذه المحادثة؟", "collisionMessage": "قد تكون هذه المحادثة ما زالت مفتوحة في طرفية على جهازك — {{reasons}}. قد يؤدي استئنافها هنا إلى التداخل مع تلك الجلسة.", diff --git a/locales/en/conversation.json b/locales/en/conversation.json index 8d4db0e9..f3f1ff9b 100644 --- a/locales/en/conversation.json +++ b/locales/en/conversation.json @@ -19,6 +19,7 @@ }, "resume": { "start": "Resume Session", + "backToLive": "Back to Live Session", "resuming": "Resuming…", "collisionTitle": "Resume this conversation?", "collisionMessage": "This conversation may still be open in a terminal on your computer — {{reasons}}. Resuming it here could interfere with that session.", diff --git a/locales/he/conversation.json b/locales/he/conversation.json index 59ef83ca..9e36b590 100644 --- a/locales/he/conversation.json +++ b/locales/he/conversation.json @@ -19,6 +19,7 @@ }, "resume": { "start": "המשך סשן", + "backToLive": "חזרה לסשן החי", "resuming": "ממשיך…", "collisionTitle": "להמשיך את השיחה הזו?", "collisionMessage": "ייתכן שהשיחה הזו עדיין פתוחה בטרמינל במחשב שלך — {{reasons}}. המשך כאן עלול להפריע לאותו סשן.", diff --git a/locales/ru/conversation.json b/locales/ru/conversation.json index 39290504..cbed0c4e 100644 --- a/locales/ru/conversation.json +++ b/locales/ru/conversation.json @@ -19,6 +19,7 @@ }, "resume": { "start": "Возобновить сессию", + "backToLive": "Назад к живой сессии", "resuming": "Возобновление…", "collisionTitle": "Возобновить этот разговор?", "collisionMessage": "Возможно, этот разговор всё ещё открыт в терминале на вашем компьютере — {{reasons}}. Возобновление здесь может помешать той сессии.",