Skip to content
Closed
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
4 changes: 3 additions & 1 deletion __tests__/integration/components/TerminalView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
})
35 changes: 34 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,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(<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')
})
})

describe('conversation detail — 404 live-session fallback', () => {
Expand Down
18 changes: 18 additions & 0 deletions __tests__/unit/lib/conversationHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
})
47 changes: 34 additions & 13 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,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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -823,12 +840,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
8 changes: 6 additions & 2 deletions components/terminal/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions lib/conversationHref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions locales/ar/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"resume": {
"start": "استئناف السيشن",
"backToLive": "العودة إلى الجلسة المباشرة",
"resuming": "جارٍ الاستئناف…",
"collisionTitle": "استئناف هذه المحادثة؟",
"collisionMessage": "قد تكون هذه المحادثة ما زالت مفتوحة في طرفية على جهازك — {{reasons}}. قد يؤدي استئنافها هنا إلى التداخل مع تلك الجلسة.",
Expand Down
1 change: 1 addition & 0 deletions locales/en/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions locales/he/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"resume": {
"start": "המשך סשן",
"backToLive": "חזרה לסשן החי",
"resuming": "ממשיך…",
"collisionTitle": "להמשיך את השיחה הזו?",
"collisionMessage": "ייתכן שהשיחה הזו עדיין פתוחה בטרמינל במחשב שלך — {{reasons}}. המשך כאן עלול להפריע לאותו סשן.",
Expand Down
1 change: 1 addition & 0 deletions locales/ru/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"resume": {
"start": "Возобновить сессию",
"backToLive": "Назад к живой сессии",
"resuming": "Возобновление…",
"collisionTitle": "Возобновить этот разговор?",
"collisionMessage": "Возможно, этот разговор всё ещё открыт в терминале на вашем компьютере — {{reasons}}. Возобновление здесь может помешать той сессии.",
Expand Down
Loading