Skip to content

Commit a4fa8ae

Browse files
committed
Merge branch 'codex/load-older-thread-messages' into main
2 parents e3c5c19 + bba76e6 commit a4fa8ae

8 files changed

Lines changed: 316 additions & 24 deletions

File tree

src/App.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,9 @@
896896
:active-thread-id="composerThreadContextId" :cwd="composerCwd"
897897
:live-overlay="liveOverlay"
898898
:pending-requests="selectedThreadServerRequests"
899+
:has-more-persisted-above="hasMoreOlderMessages"
900+
:is-loading-persisted-above="isLoadingOlderMessages"
901+
:load-earlier-messages="loadOlderMessages"
899902
@fork-thread="onForkThreadFromMessage"
900903
@rollback="onRollback"
901904
@implement-plan="onImplementPlan"
@@ -1282,9 +1285,11 @@ const {
12821285
installedSkills,
12831286
accountRateLimitSnapshots,
12841287
messages,
1288+
hasMoreOlderMessages,
12851289
isLoadingThreads,
12861290
isThreadListFullyLoaded,
12871291
isLoadingMessages,
1292+
isLoadingOlderMessages,
12881293
isSendingMessage,
12891294
isInterruptingTurn,
12901295
isSelectedThreadInterruptPending,
@@ -1293,6 +1298,7 @@ const {
12931298
refreshSkills,
12941299
selectThread,
12951300
ensureThreadMessagesLoaded,
1301+
loadOlderMessages,
12961302
setThreadTerminalOpen,
12971303
toggleSelectedThreadTerminal,
12981304
archiveThreadById,

src/api/codexGateway.ts

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -579,19 +579,26 @@ function normalizeThreadFileChangeFallback(value: unknown): ThreadFileChangeFall
579579
return normalized
580580
}
581581

582-
function buildTurnIndexByTurnId(payload: ThreadReadResponse): ThreadTurnIndexById {
582+
function buildTurnIndexByTurnId(payload: ThreadReadResponse, baseTurnIndex = 0): ThreadTurnIndexById {
583583
const turns = Array.isArray(payload.thread.turns) ? payload.thread.turns : []
584584
const lookup: ThreadTurnIndexById = {}
585585

586-
for (let turnIndex = 0; turnIndex < turns.length; turnIndex += 1) {
587-
const turn = turns[turnIndex]
586+
for (let turnOffset = 0; turnOffset < turns.length; turnOffset += 1) {
587+
const turnIndex = baseTurnIndex + turnOffset
588+
const turn = turns[turnOffset]
588589
if (typeof turn?.id !== 'string' || turn.id.length === 0) continue
589590
lookup[turn.id] = turnIndex
590591
}
591592

592593
return lookup
593594
}
594595

596+
function readThreadTurnStartIndex(payload: ThreadReadResponse): number {
597+
const record = asRecord(payload)
598+
const raw = record?.threadTurnStartIndex
599+
return Math.max(0, Math.floor(typeof raw === 'number' ? raw : 0))
600+
}
601+
595602
async function fetchThreadFileChangeFallback(threadId: string): Promise<ThreadFileChangeFallbackEntry[]> {
596603
const response = await fetch(`/codex-api/thread-file-change-fallback?threadId=${encodeURIComponent(threadId)}`)
597604
if (!response.ok) {
@@ -693,6 +700,15 @@ export type ThreadGroupsPage = {
693700
nextCursor: string | null
694701
}
695702

703+
export type ThreadTurnPage = {
704+
messages: UiMessage[]
705+
inProgress: boolean
706+
activeTurnId: string
707+
hasMoreOlder: boolean
708+
startTurnIndex: number
709+
turnIndexByTurnId: ThreadTurnIndexById
710+
}
711+
696712
async function getThreadGroupsPageV2(cursor: string | null, limit: number): Promise<ThreadGroupsPage> {
697713
const payload = await callRpc<ThreadListResponse>('thread/list', {
698714
archived: false,
@@ -714,7 +730,7 @@ async function getThreadMessagesV2(threadId: string): Promise<UiMessage[]> {
714730
threadId,
715731
includeTurns: true,
716732
})
717-
return normalizeThreadMessagesV2(payload)
733+
return normalizeThreadMessagesV2(payload, readThreadTurnStartIndex(payload))
718734
}
719735

720736
async function getThreadSummaryV2(threadId: string): Promise<UiThread> {
@@ -729,18 +745,51 @@ async function getThreadDetailV2(threadId: string): Promise<{
729745
messages: UiMessage[]
730746
inProgress: boolean
731747
activeTurnId: string
748+
hasMoreOlder: boolean
732749
turnIndexByTurnId: ThreadTurnIndexById
733750
}> {
734751
const payload = await callRpc<ThreadReadResponse>('thread/read', {
735752
threadId,
736753
includeTurns: true,
737754
})
738-
const normalized = normalizeThreadMessagesV2(payload)
755+
const startTurnIndex = readThreadTurnStartIndex(payload)
756+
const normalized = normalizeThreadMessagesV2(payload, startTurnIndex)
739757
return {
740758
messages: normalized,
741759
inProgress: readThreadInProgressFromResponse(payload),
742760
activeTurnId: readActiveTurnIdFromResponse(payload),
743-
turnIndexByTurnId: buildTurnIndexByTurnId(payload),
761+
hasMoreOlder: startTurnIndex > 0,
762+
turnIndexByTurnId: buildTurnIndexByTurnId(payload, startTurnIndex),
763+
}
764+
}
765+
766+
async function getOlderThreadMessagesV2(threadId: string, beforeTurnId: string, limit = 10): Promise<ThreadTurnPage> {
767+
const params = new URLSearchParams({
768+
threadId,
769+
beforeTurnId,
770+
limit: String(limit),
771+
})
772+
const response = await fetch(`/codex-api/thread-turn-page?${params.toString()}`)
773+
if (!response.ok) {
774+
throw new Error(`Older thread page request failed with ${response.status}`)
775+
}
776+
const payload = await response.json() as {
777+
result?: ThreadReadResponse
778+
hasMoreOlder?: unknown
779+
startTurnIndex?: unknown
780+
}
781+
if (!payload.result) {
782+
throw new Error('Older thread page response did not include a thread result')
783+
}
784+
const startTurnIndex = Math.max(0, Math.floor(typeof payload.startTurnIndex === 'number' ? payload.startTurnIndex : 0))
785+
786+
return {
787+
messages: normalizeThreadMessagesV2(payload.result, startTurnIndex),
788+
inProgress: readThreadInProgressFromResponse(payload.result),
789+
activeTurnId: readActiveTurnIdFromResponse(payload.result),
790+
hasMoreOlder: payload.hasMoreOlder === true,
791+
startTurnIndex,
792+
turnIndexByTurnId: buildTurnIndexByTurnId(payload.result, startTurnIndex),
744793
}
745794
}
746795

@@ -787,6 +836,7 @@ export async function getThreadDetail(threadId: string): Promise<{
787836
messages: UiMessage[]
788837
inProgress: boolean
789838
activeTurnId: string
839+
hasMoreOlder: boolean
790840
turnIndexByTurnId: ThreadTurnIndexById
791841
}> {
792842
try {
@@ -796,6 +846,14 @@ export async function getThreadDetail(threadId: string): Promise<{
796846
}
797847
}
798848

849+
export async function getOlderThreadMessages(threadId: string, beforeTurnId: string, limit?: number): Promise<ThreadTurnPage> {
850+
try {
851+
return await getOlderThreadMessagesV2(threadId, beforeTurnId, limit)
852+
} catch (error) {
853+
throw normalizeCodexApiError(error, `Failed to load earlier messages for thread ${threadId}`, 'thread/read')
854+
}
855+
}
856+
799857
function normalizeReviewLine(value: unknown): UiReviewLine | null {
800858
const record = asRecord(value)
801859
if (!record) return null
@@ -1352,17 +1410,21 @@ export type ResumedThread = {
13521410
messages: UiMessage[]
13531411
inProgress: boolean
13541412
activeTurnId: string
1413+
hasMoreOlder: boolean
13551414
turnIndexByTurnId: ThreadTurnIndexById
13561415
}
13571416

13581417
export async function resumeThread(threadId: string): Promise<ResumedThread> {
13591418
const payload = await callRpc<ThreadResumeResponse>('thread/resume', { threadId })
1419+
const startTurnIndex = readThreadTurnStartIndex(payload)
1420+
const messages = normalizeThreadMessagesV2(payload, startTurnIndex)
13601421
return {
13611422
model: normalizeThreadModelFromPayload(payload),
1362-
messages: normalizeThreadMessagesV2(payload),
1423+
messages,
13631424
inProgress: readThreadInProgressFromResponse(payload),
13641425
activeTurnId: readActiveTurnIdFromResponse(payload),
1365-
turnIndexByTurnId: buildTurnIndexByTurnId(payload),
1426+
hasMoreOlder: startTurnIndex > 0,
1427+
turnIndexByTurnId: buildTurnIndexByTurnId(payload, startTurnIndex),
13661428
}
13671429
}
13681430

@@ -1376,7 +1438,7 @@ export async function renameThread(threadId: string, threadName: string): Promis
13761438

13771439
export async function rollbackThread(threadId: string, numTurns: number): Promise<UiMessage[]> {
13781440
const payload = await callRpc<ThreadReadResponse>('thread/rollback', { threadId, numTurns })
1379-
return normalizeThreadMessagesV2(payload)
1441+
return normalizeThreadMessagesV2(payload, readThreadTurnStartIndex(payload))
13801442
}
13811443

13821444
export async function revertThreadFileChanges(threadId: string, turnId: string, cwd: string): Promise<{ reverted: number; errors: string[] }> {
@@ -1483,7 +1545,7 @@ export async function forkThread(
14831545
threadId: forkedThreadId,
14841546
cwd: normalizeThreadCwdFromPayload(payload),
14851547
model: normalizeThreadModelFromPayload(payload),
1486-
messages: normalizeThreadMessagesV2(payload),
1548+
messages: normalizeThreadMessagesV2(payload, readThreadTurnStartIndex(payload)),
14871549
}
14881550
} catch (error) {
14891551
throw normalizeCodexApiError(error, `Failed to fork thread ${threadId}`, 'thread/fork')

src/api/normalizers/v2.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,19 @@ Reply with &lt;/instructions&gt; and A &amp; B
9090
automationDisplayName: 'automation-1',
9191
})
9292
})
93+
94+
it('applies a base turn index for paged thread slices', () => {
95+
const messages = normalizeThreadMessagesV2(threadReadResponseWithContent([{
96+
type: 'userMessage',
97+
id: 'user-3',
98+
content: [{ type: 'text', text: 'Paged message', text_elements: [] }],
99+
}]), 12)
100+
101+
expect(messages).toHaveLength(1)
102+
expect(messages[0]).toMatchObject({
103+
id: 'user-3',
104+
turnId: 'turn-1',
105+
turnIndex: 12,
106+
})
107+
})
93108
})

src/api/normalizers/v2.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,11 +618,12 @@ export function normalizeThreadGroupsV2(payload: ThreadListResponse): UiProjectG
618618
return groupThreadsByProject(uiThreads)
619619
}
620620

621-
export function normalizeThreadMessagesV2(payload: ThreadReadResponse): UiMessage[] {
621+
export function normalizeThreadMessagesV2(payload: ThreadReadResponse, baseTurnIndex = 0): UiMessage[] {
622622
const turns = Array.isArray(payload.thread.turns) ? payload.thread.turns : []
623623
const messages: UiMessage[] = []
624-
for (let turnIndex = 0; turnIndex < turns.length; turnIndex++) {
625-
const turn = turns[turnIndex]
624+
for (let turnOffset = 0; turnOffset < turns.length; turnOffset++) {
625+
const turnIndex = baseTurnIndex + turnOffset
626+
const turn = turns[turnOffset]
626627
const turnId = typeof turn?.id === 'string' ? turn.id : undefined
627628
const items = Array.isArray(turn.items) ? turn.items : []
628629
for (const item of items) {

src/components/content/ThreadConversation.vue

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
<button
1515
type="button"
1616
class="load-more-button"
17-
:disabled="isLoadingMore"
17+
:disabled="isLoadingMore || isLoadingPersistedAbove"
1818
@click="loadMoreAbove"
1919
>
20-
{{ isLoadingMore ? 'Loading…' : 'Load earlier messages' }}
20+
{{ isLoadingMore || isLoadingPersistedAbove ? 'Loading…' : 'Load earlier messages' }}
2121
</button>
2222
</li>
2323
<template v-for="message in visibleMessages" :key="message.id">
@@ -1238,6 +1238,9 @@ const props = defineProps<{
12381238
isLoading: boolean
12391239
activeThreadId: string
12401240
cwd: string
1241+
hasMorePersistedAbove?: boolean
1242+
isLoadingPersistedAbove?: boolean
1243+
loadEarlierMessages?: (threadId: string) => Promise<void>
12411244
}>()
12421245

12431246
const emit = defineEmits<{
@@ -1353,7 +1356,7 @@ const renderWindowStart = ref(0)
13531356
const isLoadingMore = ref(false)
13541357

13551358
const visibleMessages = computed(() => props.messages.slice(renderWindowStart.value))
1356-
const hasMoreAbove = computed(() => renderWindowStart.value > 0)
1359+
const hasMoreAbove = computed(() => renderWindowStart.value > 0 || props.hasMorePersistedAbove === true)
13571360

13581361
const showJumpToLatestButton = computed(
13591362
() => !autoFollowOutput.value && (props.messages.length > 0 || props.pendingRequests.length > 0 || Boolean(props.liveOverlay)),
@@ -4011,21 +4014,28 @@ function jumpToLatest(): void {
40114014

40124015
async function loadMoreAbove(): Promise<void> {
40134016
const container = conversationListRef.value
4014-
if (!container || !hasMoreAbove.value || isLoadingMore.value) return
4017+
if (!container || !hasMoreAbove.value || isLoadingMore.value || props.isLoadingPersistedAbove === true) return
40154018

40164019
isLoadingMore.value = true
40174020
const threadIdAtStart = props.activeThreadId
40184021

40194022
const prevScrollHeight = container.scrollHeight
40204023
const prevScrollTop = container.scrollTop
40214024

4022-
renderWindowStart.value = Math.max(0, renderWindowStart.value - LOAD_MORE_CHUNK)
4025+
try {
4026+
if (renderWindowStart.value > 0) {
4027+
renderWindowStart.value = Math.max(0, renderWindowStart.value - LOAD_MORE_CHUNK)
4028+
} else if (props.hasMorePersistedAbove === true) {
4029+
await props.loadEarlierMessages?.(threadIdAtStart)
4030+
}
40234031

4024-
await nextTick()
4032+
await nextTick()
40254033

4026-
// Discard scroll restoration if the thread changed while we were awaiting.
4027-
if (props.activeThreadId === threadIdAtStart) {
4028-
container.scrollTop = prevScrollTop + (container.scrollHeight - prevScrollHeight)
4034+
// Discard scroll restoration if the thread changed while we were awaiting.
4035+
if (props.activeThreadId === threadIdAtStart) {
4036+
container.scrollTop = prevScrollTop + (container.scrollHeight - prevScrollHeight)
4037+
}
4038+
} finally {
40294039
isLoadingMore.value = false
40304040
}
40314041
}

0 commit comments

Comments
 (0)