Skip to content
Open
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
38 changes: 37 additions & 1 deletion __tests__/integration/components/TerminalOutput.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(
<TerminalOutput lines={[]} isStreaming={false} />
)
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(
<TerminalOutput
lines={['startup']}
isStreaming={false}
onViewResumedConversation={onView}
/>
)
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(
<TerminalOutput
lines={[]}
isStreaming={false}
onViewResumedConversation={onView}
/>
)
await fireEvent.press(getByTestId('terminal-resumed-scrollback-notice'))
expect(onView).toHaveBeenCalledTimes(1)
})
})
24 changes: 22 additions & 2 deletions __tests__/integration/components/TerminalView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
<TerminalView serverId="srv1" sessionId="sess1" />,
<TerminalView serverId="srv1" sessionId="sess1" {...props} />,
{ wrapper: createWrapper() },
)
}
Expand All @@ -104,6 +110,7 @@ describe('TerminalView', () => {
beforeEach(() => {
mockSendInputMutate.mockClear()
mockSendKeysMutate.mockClear()
mockPush.mockClear()
})

it('renders terminal-line-row elements when lines are provided', async () => {
Expand All @@ -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')
})
})
1 change: 1 addition & 0 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@
})
})
return unsub
}, [

Check warning on line 558 in app/session/[id].tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'stopSession'. Either include it or remove the dependency array
navigation,
isPending,
id,
Expand Down Expand Up @@ -1008,6 +1008,7 @@
disabled={isWakingUp}
pendingPlan={planVisible ? pendingPlan : null}
onClosePlan={() => { setPlanVisible(false); setPendingPlan(null) }}
resumedConversationId={session.resumedFromConversationId}
/>
) : (
<LiveConversationView
Expand Down
56 changes: 55 additions & 1 deletion components/terminal/TerminalOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,25 @@ interface Props {
activeQuestion?: QuestionBlock | null
/** Answer a structured AskUserQuestion (POST). Permission gates answer via onSendKeys. */
onAnswer?: (toolUseId: string, answers: Record<string, string | string[]>) => 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],
Expand Down Expand Up @@ -211,13 +226,30 @@ 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 (
<TouchableOpacity
style={styles.resumedNotice}
onPress={onViewResumedConversation}
accessibilityRole="link"
accessibilityLabel={`${tTerminal('session.resumedEmptyScrollback')} ${tTerminal('session.resumedEmptyScrollbackLink')}`}
testID="terminal-resumed-scrollback-notice"
>
<Text style={styles.resumedNoticeText}>{tTerminal('session.resumedEmptyScrollback')}</Text>
<Text style={styles.resumedNoticeLink}>{tTerminal('session.resumedEmptyScrollbackLink')}</Text>
</TouchableOpacity>
)
}, [onViewResumedConversation, tTerminal])

return (
<View style={styles.container}>
<FlashList
ref={listRef}
data={collapsedLines}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListHeaderComponent={listHeader}
onScroll={handleScroll}
scrollEventThrottle={100}
maintainVisibleContentPosition={{
Expand Down Expand Up @@ -277,6 +309,28 @@ const styles = StyleSheet.create({
paddingVertical: 8,
paddingHorizontal: 4,
},
resumedNotice: {
marginHorizontal: 8,
marginBottom: 8,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#21262d',
borderRadius: 8,
borderWidth: 1,
borderColor: '#30363d',
gap: 4,
},
resumedNoticeText: {
color: '#8b949e',
fontSize: 12,
lineHeight: 16,
},
resumedNoticeLink: {
color: '#58a6ff',
fontSize: 12,
lineHeight: 16,
fontWeight: '500',
},
lineRow: {
flexDirection: 'row',
paddingHorizontal: 8,
Expand Down
14 changes: 13 additions & 1 deletion components/terminal/TerminalView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import React, { useCallback } from 'react'
import { Alert, View, Text, StyleSheet } from 'react-native'
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { Warning } from 'phosphor-react-native'
import { useTerminalStream } from '@/hooks/useTerminalStream'
Expand All @@ -13,6 +14,7 @@ import { SlashCommandBoard } from '@/components/shared/SlashCommandBoard'
import { SlashCommandArgModal } from '@/components/shared/SlashCommandArgModal'
import { PromptQueueSheet } from '@/components/queue/PromptQueueSheet'
import { PlanPreviewSheet } from '@/components/queue/PlanPreviewSheet'
import { conversationHref } from '@/lib/conversationHref'
import { markSessionUsed } from '@/lib/sessionUsage'
import type { ProviderName } from '@/constants/providers'
import type { ParseConfidence } from '@/lib/renderConfidence'
Expand All @@ -25,6 +27,8 @@ interface Props {
disabled?: boolean
pendingPlan?: string | null
onClosePlan?: () => void
/** Conversation that was resumed into this session — when set, disclose missing PTY scrollback. */
resumedConversationId?: string | null
}

export function TerminalView({
Expand All @@ -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,
Expand All @@ -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, {
Expand Down Expand Up @@ -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}
/>
<ChatComposer
value={inputText}
Expand Down
2 changes: 1 addition & 1 deletion locales/ar/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"provider": {
"unavailable": "CLI المزوّد غير مثبت على الخادم. اختر مزوّدًا آخر أو ثبّته على المضيف.",
"noStructuredQuestions": "لا قوائم أسئلة منظمة لهذا المزوّد — ستجيب في الطرفية الخام.",
"noStructuredQuestions": "لا قوائم أسئلة منظمة لهذا المزوّد — ستجيب في طرفية RAW.",
"observeOnly": "التحكم المباشر غير متاح لهذا المزوّد؛ الجلسات للمراقبة فقط.",
"warning": {
"provider_not_found": "لم يُعثر على CLI المزوّد في PATH الخادم.",
Expand Down
2 changes: 1 addition & 1 deletion locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"session": {
"chatView": "عرض الدردشة",
"chatViewNote": "أظهر السيشنات المباشرة كفقاعات دردشة بدلًا من مخرجات الطرفية الخام. بيتا — قد تحتوي على أخطاء ولا تعمل دائمًا.",
"chatViewNote": "أظهر السيشنات المباشرة كفقاعات دردشة بدلًا من مخرجات طرفية RAW. بيتا — قد تحتوي على أخطاء ولا تعمل دائمًا.",
"betaBadge": "بيتا"
},
"permissions": {
Expand Down
4 changes: 3 additions & 1 deletion locales/ar/terminal.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
"viewModeTerminal": "طرفية",
"viewModeChat": "محادثة",
"ptyActiveFallbackBanner": "مخرجات الطرفية المباشرة نشطة قبل توفر رسائل المحادثة.",
"rawModeNote": "وضع الطرفية الخام — تم اكتشاف تسلسلات غير مدعومة؛ المخرجات غير مفلترة."
"rawModeNote": "وضع RAW — تم اكتشاف تسلسلات غير مدعومة؛ المخرجات غير مفلترة.",
"resumedEmptyScrollback": "المخرجات السابقة غير متاحة لسيشن مُستأنف.",
"resumedEmptyScrollbackLink": "عرض سجل المحادثة ←"
},
"a11y": {
"line": "السطر {{n}}: {{text}}"
Expand Down
2 changes: 1 addition & 1 deletion locales/en/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"provider": {
"unavailable": "This provider’s CLI is not installed on the server. Choose another provider or install it on the host.",
"noStructuredQuestions": "This provider has no structured question menus — you’ll answer in the raw terminal.",
"noStructuredQuestions": "This provider has no structured question menus — you’ll answer in the RAW terminal.",
"observeOnly": "Live control is unavailable for this provider; sessions are observe-only.",
"warning": {
"provider_not_found": "Provider CLI not found on the server PATH.",
Expand Down
2 changes: 1 addition & 1 deletion locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"session": {
"chatView": "Chat view",
"chatViewNote": "Show live sessions as chat bubbles instead of raw terminal output. Beta — may be buggy and not always work.",
"chatViewNote": "Show live sessions as chat bubbles instead of RAW terminal output. Beta — may be buggy and not always work.",
"betaBadge": "Beta"
},
"permissions": {
Expand Down
4 changes: 3 additions & 1 deletion locales/en/terminal.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
"viewModeTerminal": "Terminal",
"viewModeChat": "Chat",
"ptyActiveFallbackBanner": "Live terminal output is active before conversation messages are available.",
"rawModeNote": "Raw terminal mode — unsupported sequences detected; output is unfiltered."
"rawModeNote": "RAW terminal mode — unsupported sequences detected; output is unfiltered.",
"resumedEmptyScrollback": "Earlier output isn't available for a resumed session.",
"resumedEmptyScrollbackLink": "View the conversation history →"
},
"a11y": {
"line": "Line {{n}}: {{text}}"
Expand Down
2 changes: 1 addition & 1 deletion locales/he/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"provider": {
"unavailable": "ה־CLI של הספק אינו מותקן בשרת. בחר ספק אחר או התקן במארח.",
"noStructuredQuestions": "לספק זה אין תפריטי שאלות מובנים — תענה במסוף הגולמי.",
"noStructuredQuestions": "לספק זה אין תפריטי שאלות מובנים — תענה בטרמינל RAW.",
"observeOnly": "שליטה חיה אינה זמינה לספק זה; הסשנים לצפייה בלבד.",
"warning": {
"provider_not_found": "ה־CLI של הספק לא נמצא ב־PATH של השרת.",
Expand Down
2 changes: 1 addition & 1 deletion locales/he/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"session": {
"chatView": "תצוגת צ'אט",
"chatViewNote": "הצג סשנים חיים כבועות צ'אט במקום פלט טרמינל גולמי. בטא — ייתכנו באגים והתכונה לא תמיד תעבוד.",
"chatViewNote": "הצג סשנים חיים כבועות צ'אט במקום פלט טרמינל RAW. בטא — ייתכנו באגים והתכונה לא תמיד תעבוד.",
"betaBadge": "בטא"
},
"permissions": {
Expand Down
4 changes: 3 additions & 1 deletion locales/he/terminal.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
"viewModeTerminal": "טרמינל",
"viewModeChat": "צ׳אט",
"ptyActiveFallbackBanner": "פלט טרמינל חי פעיל לפני שזמינים הודעות שיחה.",
"rawModeNote": "מצב טרמינל גולמי — זוהו רצפים שאינם נתמכים; הפלט אינו מסונן."
"rawModeNote": "מצב RAW — זוהו רצפים שאינם נתמכים; הפלט אינו מסונן.",
"resumedEmptyScrollback": "פלט קודם אינו זמין בסשן שחודש.",
"resumedEmptyScrollbackLink": "צפה בהיסטוריית השיחה ←"
},
"a11y": {
"line": "שורה {{n}}: {{text}}"
Expand Down
2 changes: 1 addition & 1 deletion locales/ru/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"provider": {
"unavailable": "CLI провайдера не установлен на сервере. Выберите другого или установите на хосте.",
"noStructuredQuestions": "У этого провайдера нет структурированных меню вопросов — отвечайте в сыром терминале.",
"noStructuredQuestions": "У этого провайдера нет структурированных меню вопросов — отвечайте в терминале RAW.",
"observeOnly": "Живое управление недоступно; сессии только для наблюдения.",
"warning": {
"provider_not_found": "CLI провайдера не найден в PATH сервера.",
Expand Down
2 changes: 1 addition & 1 deletion locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"session": {
"chatView": "Вид чата",
"chatViewNote": "Показывать активные сессии в виде чат-пузырей вместо необработанного вывода терминала. Бета — может работать нестабильно.",
"chatViewNote": "Показывать активные сессии в виде чат-пузырей вместо вывода терминала RAW. Бета — может работать нестабильно.",
"betaBadge": "Бета"
},
"permissions": {
Expand Down
4 changes: 3 additions & 1 deletion locales/ru/terminal.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
"viewModeTerminal": "Терминал",
"viewModeChat": "Чат",
"ptyActiveFallbackBanner": "Живой вывод терминала активен до появления сообщений беседы.",
"rawModeNote": "Сырой терминал — обнаружены неподдерживаемые последовательности; вывод без фильтра."
"rawModeNote": "Режим RAW — обнаружены неподдерживаемые последовательности; вывод без фильтра.",
"resumedEmptyScrollback": "Предыдущий вывод недоступен для возобновлённой сессии.",
"resumedEmptyScrollbackLink": "Открыть историю беседы →"
},
"a11y": {
"line": "Строка {{n}}: {{text}}"
Expand Down
Loading