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}}. Возобновление здесь может помешать той сессии.",