Skip to content
Merged
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
34 changes: 33 additions & 1 deletion __tests__/unit/utils/inheritedHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' })
})
})
20 changes: 19 additions & 1 deletion app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'),
Expand Down
3 changes: 2 additions & 1 deletion locales/ar/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"inheritedHistory": {
"forked": "تفرَّع إلى Threadbase",
"forkedAt": "تفرَّع إلى Threadbase · {{time}}",
"unavailable": "لم يعد السجل الأقدم السابق للتفرُّع متاحًا."
"unavailable": "لم يعد السجل الأقدم السابق للتفرُّع متاحًا.",
"openSource": "الانتقال إلى الجلسة الأصلية"
},
"resume": {
"start": "استئناف السيشن",
Expand Down
3 changes: 2 additions & 1 deletion locales/en/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/he/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"inheritedHistory": {
"forked": "פוצל אל Threadbase",
"forkedAt": "פוצל אל Threadbase · {{time}}",
"unavailable": "ההיסטוריה שקדמה לפיצול אינה זמינה עוד."
"unavailable": "ההיסטוריה שקדמה לפיצול אינה זמינה עוד.",
"openSource": "מעבר לשיחת המקור"
},
"resume": {
"start": "המשך סשן",
Expand Down
3 changes: 2 additions & 1 deletion locales/ru/conversation.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"inheritedHistory": {
"forked": "Форк в Threadbase",
"forkedAt": "Форк в Threadbase · {{time}}",
"unavailable": "Более ранняя история до форка больше недоступна."
"unavailable": "Более ранняя история до форка больше недоступна.",
"openSource": "Перейти к исходной сессии"
},
"resume": {
"start": "Возобновить сессию",
Expand Down
7 changes: 5 additions & 2 deletions utils/inheritedHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }
}
Loading