From b15431c5c0f79144ed1126292d132eef0b640112 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Mon, 3 Aug 2026 17:27:50 +0300 Subject: [PATCH 1/3] fix(terminal): disclose missing scrollback after resume PTY ring buffers die with the process, so resumed sessions start empty. Show an explicit link to conversation history instead of a silent blank scrollback. --- .../components/TerminalOutput.test.tsx | 38 ++++++++++++- .../components/TerminalView.test.tsx | 24 +++++++- app/session/[id].tsx | 1 + components/terminal/TerminalOutput.tsx | 56 ++++++++++++++++++- components/terminal/TerminalView.tsx | 14 ++++- locales/ar/terminal.json | 4 +- locales/en/terminal.json | 4 +- locales/he/terminal.json | 4 +- locales/ru/terminal.json | 4 +- 9 files changed, 140 insertions(+), 9 deletions(-) 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} /> Date: Mon, 3 Aug 2026 17:35:51 +0300 Subject: [PATCH 2/3] fix(i18n): keep RAW as an English mode name in terminal copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace food/materials-sense translations (גולמי / сырой / الخام) with uppercase RAW so the mode label stays recognizable across locales. --- locales/ar/terminal.json | 2 +- locales/en/terminal.json | 2 +- locales/he/terminal.json | 2 +- locales/ru/terminal.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/locales/ar/terminal.json b/locales/ar/terminal.json index c537232f..20ad48b4 100644 --- a/locales/ar/terminal.json +++ b/locales/ar/terminal.json @@ -57,7 +57,7 @@ "viewModeTerminal": "طرفية", "viewModeChat": "محادثة", "ptyActiveFallbackBanner": "مخرجات الطرفية المباشرة نشطة قبل توفر رسائل المحادثة.", - "rawModeNote": "وضع الطرفية الخام — تم اكتشاف تسلسلات غير مدعومة؛ المخرجات غير مفلترة.", + "rawModeNote": "وضع RAW — تم اكتشاف تسلسلات غير مدعومة؛ المخرجات غير مفلترة.", "resumedEmptyScrollback": "المخرجات السابقة غير متاحة لسيشن مُستأنف.", "resumedEmptyScrollbackLink": "عرض سجل المحادثة ←" }, diff --git a/locales/en/terminal.json b/locales/en/terminal.json index 34ef57d0..bd79c1a6 100644 --- a/locales/en/terminal.json +++ b/locales/en/terminal.json @@ -53,7 +53,7 @@ "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 →" }, diff --git a/locales/he/terminal.json b/locales/he/terminal.json index 7bd8df99..51ef5744 100644 --- a/locales/he/terminal.json +++ b/locales/he/terminal.json @@ -53,7 +53,7 @@ "viewModeTerminal": "טרמינל", "viewModeChat": "צ׳אט", "ptyActiveFallbackBanner": "פלט טרמינל חי פעיל לפני שזמינים הודעות שיחה.", - "rawModeNote": "מצב טרמינל גולמי — זוהו רצפים שאינם נתמכים; הפלט אינו מסונן.", + "rawModeNote": "מצב RAW — זוהו רצפים שאינם נתמכים; הפלט אינו מסונן.", "resumedEmptyScrollback": "פלט קודם אינו זמין בסשן שחודש.", "resumedEmptyScrollbackLink": "צפה בהיסטוריית השיחה ←" }, diff --git a/locales/ru/terminal.json b/locales/ru/terminal.json index 70d812f8..3690eb0a 100644 --- a/locales/ru/terminal.json +++ b/locales/ru/terminal.json @@ -55,7 +55,7 @@ "viewModeTerminal": "Терминал", "viewModeChat": "Чат", "ptyActiveFallbackBanner": "Живой вывод терминала активен до появления сообщений беседы.", - "rawModeNote": "Сырой терминал — обнаружены неподдерживаемые последовательности; вывод без фильтра.", + "rawModeNote": "Режим RAW — обнаружены неподдерживаемые последовательности; вывод без фильтра.", "resumedEmptyScrollback": "Предыдущий вывод недоступен для возобновлённой сессии.", "resumedEmptyScrollbackLink": "Открыть историю беседы →" }, From 8e09f6020dc652e76bd2fe49797b0b360f7de6ea Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Mon, 3 Aug 2026 17:37:35 +0300 Subject: [PATCH 3/3] fix(i18n): align RAW terminal wording across settings and browse Use uppercase RAW as the shared mode name everywhere the UI refers to unfiltered terminal output, matching the terminal banner copy. --- locales/ar/browse.json | 2 +- locales/ar/settings.json | 2 +- locales/en/browse.json | 2 +- locales/en/settings.json | 2 +- locales/he/browse.json | 2 +- locales/he/settings.json | 2 +- locales/ru/browse.json | 2 +- locales/ru/settings.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/locales/ar/browse.json b/locales/ar/browse.json index d4d34eb0..a280b2ee 100644 --- a/locales/ar/browse.json +++ b/locales/ar/browse.json @@ -29,7 +29,7 @@ }, "provider": { "unavailable": "CLI المزوّد غير مثبت على الخادم. اختر مزوّدًا آخر أو ثبّته على المضيف.", - "noStructuredQuestions": "لا قوائم أسئلة منظمة لهذا المزوّد — ستجيب في الطرفية الخام.", + "noStructuredQuestions": "لا قوائم أسئلة منظمة لهذا المزوّد — ستجيب في طرفية RAW.", "observeOnly": "التحكم المباشر غير متاح لهذا المزوّد؛ الجلسات للمراقبة فقط.", "warning": { "provider_not_found": "لم يُعثر على CLI المزوّد في PATH الخادم.", diff --git a/locales/ar/settings.json b/locales/ar/settings.json index 28372ea3..7ab9e434 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -18,7 +18,7 @@ }, "session": { "chatView": "عرض الدردشة", - "chatViewNote": "أظهر السيشنات المباشرة كفقاعات دردشة بدلًا من مخرجات الطرفية الخام. بيتا — قد تحتوي على أخطاء ولا تعمل دائمًا.", + "chatViewNote": "أظهر السيشنات المباشرة كفقاعات دردشة بدلًا من مخرجات طرفية RAW. بيتا — قد تحتوي على أخطاء ولا تعمل دائمًا.", "betaBadge": "بيتا" }, "permissions": { diff --git a/locales/en/browse.json b/locales/en/browse.json index 86004878..7e575d89 100644 --- a/locales/en/browse.json +++ b/locales/en/browse.json @@ -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.", diff --git a/locales/en/settings.json b/locales/en/settings.json index a75f02ae..56d20189 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -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": { diff --git a/locales/he/browse.json b/locales/he/browse.json index f7eb0269..a7870e4d 100644 --- a/locales/he/browse.json +++ b/locales/he/browse.json @@ -29,7 +29,7 @@ }, "provider": { "unavailable": "ה־CLI של הספק אינו מותקן בשרת. בחר ספק אחר או התקן במארח.", - "noStructuredQuestions": "לספק זה אין תפריטי שאלות מובנים — תענה במסוף הגולמי.", + "noStructuredQuestions": "לספק זה אין תפריטי שאלות מובנים — תענה בטרמינל RAW.", "observeOnly": "שליטה חיה אינה זמינה לספק זה; הסשנים לצפייה בלבד.", "warning": { "provider_not_found": "ה־CLI של הספק לא נמצא ב־PATH של השרת.", diff --git a/locales/he/settings.json b/locales/he/settings.json index 9655312e..46c12564 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -18,7 +18,7 @@ }, "session": { "chatView": "תצוגת צ'אט", - "chatViewNote": "הצג סשנים חיים כבועות צ'אט במקום פלט טרמינל גולמי. בטא — ייתכנו באגים והתכונה לא תמיד תעבוד.", + "chatViewNote": "הצג סשנים חיים כבועות צ'אט במקום פלט טרמינל RAW. בטא — ייתכנו באגים והתכונה לא תמיד תעבוד.", "betaBadge": "בטא" }, "permissions": { diff --git a/locales/ru/browse.json b/locales/ru/browse.json index a305d12c..5d2e1046 100644 --- a/locales/ru/browse.json +++ b/locales/ru/browse.json @@ -29,7 +29,7 @@ }, "provider": { "unavailable": "CLI провайдера не установлен на сервере. Выберите другого или установите на хосте.", - "noStructuredQuestions": "У этого провайдера нет структурированных меню вопросов — отвечайте в сыром терминале.", + "noStructuredQuestions": "У этого провайдера нет структурированных меню вопросов — отвечайте в терминале RAW.", "observeOnly": "Живое управление недоступно; сессии только для наблюдения.", "warning": { "provider_not_found": "CLI провайдера не найден в PATH сервера.", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index 2a4dfd9c..7738e768 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -18,7 +18,7 @@ }, "session": { "chatView": "Вид чата", - "chatViewNote": "Показывать активные сессии в виде чат-пузырей вместо необработанного вывода терминала. Бета — может работать нестабильно.", + "chatViewNote": "Показывать активные сессии в виде чат-пузырей вместо вывода терминала RAW. Бета — может работать нестабильно.", "betaBadge": "Бета" }, "permissions": {