From d29d71dee91ef9d7be00192b2346c999484ad533 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Thu, 10 Sep 2026 07:13:44 +0300 Subject: [PATCH] feat(session): offer to open the conversation a fork continues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `codex fork` session inherits its opening history from another conversation, and the server already names that parent in `meta.inherited_history.source_id`. The client received the field and discarded it during narrowing, so there was no way to reach the parent as it exists now — the stitched prefix only shows it up to the fork point. Carry `source_id` onto the divider seam and add an overflow-menu entry that navigates to it. The id is read off the conversation query the diffs feature already runs, so this adds no fetch: `inherited_history` rides on the same conversation's meta regardless of the byte budget. The entry is disabled rather than hidden when there is no parent to open, which covers all three ways that happens: a non-fork, a server too old to send the field, and an `unavailable` seam whose source file is gone and which therefore carries no id. --- __tests__/unit/utils/inheritedHistory.test.ts | 34 ++++++++++++++++++- app/session/[id].tsx | 20 ++++++++++- locales/ar/conversation.json | 3 +- locales/en/conversation.json | 3 +- locales/he/conversation.json | 3 +- locales/ru/conversation.json | 3 +- utils/inheritedHistory.ts | 7 ++-- 7 files changed, 65 insertions(+), 8 deletions(-) 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 } }