diff --git a/__tests__/unit/utils/inheritedHistory.test.ts b/__tests__/unit/utils/inheritedHistory.test.ts index 0866316b..2073dfe7 100644 --- a/__tests__/unit/utils/inheritedHistory.test.ts +++ b/__tests__/unit/utils/inheritedHistory.test.ts @@ -15,7 +15,12 @@ describe('inheritedHistorySeam', () => { forked_at: '2026-09-06T17:31:07.482Z', unavailable_reason: null, }), - ).toEqual({ kind: 'divider', beforeMessageIndex: 21, forkedAt: '2026-09-06T17:31:07.482Z' }) + ).toEqual({ + kind: 'divider', + beforeMessageIndex: 21, + forkedAt: '2026-09-06T17:31:07.482Z', + sourceId: '01a075cd-f290-7d63-9bd8-b37f70c2ef5f', + }) }) it('reports an unreadable parent regardless of the other fields', () => { @@ -50,3 +55,30 @@ describe('inheritedHistorySeam', () => { ).toEqual({ kind: 'divider', beforeMessageIndex: 7, forkedAt: undefined }) }) }) + +describe('inheritedHistorySeam — source id', () => { + const base: RawInheritedHistory = { through_message_index: 4, source_id: 'rollout-parent' } + + it('carries source_id so the UI can offer to open the parent', () => { + const seam = inheritedHistorySeam(base) + expect(seam).toEqual(expect.objectContaining({ kind: 'divider', sourceId: 'rollout-parent' })) + }) + + it('omits it when the server does not send one', () => { + const seam = inheritedHistorySeam({ through_message_index: 4 }) + expect(seam).toEqual(expect.objectContaining({ kind: 'divider' })) + expect((seam as { sourceId?: string }).sourceId).toBeUndefined() + }) + + it('rejects a non-string or empty source_id rather than passing it on', () => { + for (const bad of [42, '', null, {}]) { + const seam = inheritedHistorySeam({ ...base, source_id: bad as unknown as string }) + expect((seam as { sourceId?: string }).sourceId).toBeUndefined() + } + }) + + it('has no source id on the unavailable seam, so no dead link is offered', () => { + const seam = inheritedHistorySeam({ ...base, unavailable_reason: 'source_missing' }) + expect(seam).toEqual({ kind: 'unavailable' }) + }) +}) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index d65ae1fc..6a0933a2 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -16,7 +16,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter, useNavigation } from 'expo-router' import type { Href } from 'expo-router' import * as Clipboard from 'expo-clipboard' -import { CopySimple, InfoIcon, PencilSimple, Sparkle, Star, StopCircle, GitDiff, Warning } from 'phosphor-react-native' +import { ArrowUUpLeft, CopySimple, InfoIcon, PencilSimple, Sparkle, Star, StopCircle, GitDiff, Warning } from 'phosphor-react-native' import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge' import { deriveSessionPresentation, sessionOpensAsHistory } from '@/lib/sessionPresentation' import { useSessionDetail } from '@/hooks/useSession' @@ -586,6 +586,16 @@ export default function SessionDetailScreen() { () => buildReviewFromMessages(reviewMessages).files.length > 0, [reviewMessages], ) + // A `codex fork` session continues an earlier conversation, and the server + // names that parent in meta.inherited_history. Read off the query above rather + // than adding one: the field rides on the same conversation's meta. A non-fork + // or an older server simply omits it, which reads as "no parent to open", and + // the `unavailable` seam (source file gone) carries no id — so the entry stays + // disabled rather than offering a dead link. + const forkParentId = + reviewConversation?.inheritedHistory?.kind === 'divider' + ? reviewConversation.inheritedHistory.sourceId + : undefined // Leave-session policy lives in useSessionLeaveGuard. // Do not add a second guard here. @@ -918,6 +928,14 @@ export default function SessionDetailScreen() { onPress: () => setInfoVisible(true), testID: 'session-info-button', }, + { + key: 'fork-parent', + label: t('conversation:inheritedHistory.openSource'), + icon: ArrowUUpLeft, + onPress: () => router.push(`/conversation/${forkParentId}?server=${serverId}`), + disabled: !forkParentId, + testID: 'session-fork-parent-button', + }, { key: 'diffs', label: t('conversation:review.open'), diff --git a/locales/ar/conversation.json b/locales/ar/conversation.json index 454abe58..93259892 100644 --- a/locales/ar/conversation.json +++ b/locales/ar/conversation.json @@ -25,7 +25,8 @@ "inheritedHistory": { "forked": "تفرَّع إلى Threadbase", "forkedAt": "تفرَّع إلى Threadbase · {{time}}", - "unavailable": "لم يعد السجل الأقدم السابق للتفرُّع متاحًا." + "unavailable": "لم يعد السجل الأقدم السابق للتفرُّع متاحًا.", + "openSource": "الانتقال إلى الجلسة الأصلية" }, "resume": { "start": "استئناف السيشن", diff --git a/locales/en/conversation.json b/locales/en/conversation.json index 282f80e9..a9c6f4a1 100644 --- a/locales/en/conversation.json +++ b/locales/en/conversation.json @@ -25,7 +25,8 @@ "inheritedHistory": { "forked": "Forked into Threadbase", "forkedAt": "Forked into Threadbase · {{time}}", - "unavailable": "Earlier history from before this fork isn't available." + "unavailable": "Earlier history from before this fork isn't available.", + "openSource": "Go to original session" }, "resume": { "start": "Resume Session", diff --git a/locales/he/conversation.json b/locales/he/conversation.json index c57c1e94..8c666719 100644 --- a/locales/he/conversation.json +++ b/locales/he/conversation.json @@ -25,7 +25,8 @@ "inheritedHistory": { "forked": "פוצל אל Threadbase", "forkedAt": "פוצל אל Threadbase · {{time}}", - "unavailable": "ההיסטוריה שקדמה לפיצול אינה זמינה עוד." + "unavailable": "ההיסטוריה שקדמה לפיצול אינה זמינה עוד.", + "openSource": "מעבר לשיחת המקור" }, "resume": { "start": "המשך סשן", diff --git a/locales/ru/conversation.json b/locales/ru/conversation.json index 290637c2..86850d4c 100644 --- a/locales/ru/conversation.json +++ b/locales/ru/conversation.json @@ -25,7 +25,8 @@ "inheritedHistory": { "forked": "Форк в Threadbase", "forkedAt": "Форк в Threadbase · {{time}}", - "unavailable": "Более ранняя история до форка больше недоступна." + "unavailable": "Более ранняя история до форка больше недоступна.", + "openSource": "Перейти к исходной сессии" }, "resume": { "start": "Возобновить сессию", diff --git a/utils/inheritedHistory.ts b/utils/inheritedHistory.ts index 68ddbb73..e0ca28b5 100644 --- a/utils/inheritedHistory.ts +++ b/utils/inheritedHistory.ts @@ -13,7 +13,7 @@ export interface RawInheritedHistory { /** What the message list should draw for the inherited/own boundary, if anything. */ export type InheritedHistorySeam = - | { kind: 'divider'; beforeMessageIndex: number; forkedAt?: string } + | { kind: 'divider'; beforeMessageIndex: number; forkedAt?: string; sourceId?: string } | { kind: 'unavailable' } export function inheritedHistorySeam( @@ -27,5 +27,8 @@ export function inheritedHistorySeam( if (typeof index !== 'number' || !Number.isInteger(index) || index <= 0) return undefined const forkedAt = typeof raw.forked_at === 'string' && raw.forked_at ? raw.forked_at : undefined - return { kind: 'divider', beforeMessageIndex: index, forkedAt } + // Carried so the UI can offer to open the parent. Absent on an older server, + // which reads as "no parent to navigate to" rather than an error. + const sourceId = typeof raw.source_id === 'string' && raw.source_id ? raw.source_id : undefined + return { kind: 'divider', beforeMessageIndex: index, forkedAt, sourceId } }