fix(app): use chronological message boundaries (#41006)

Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
opencode-agent[bot] 2026-08-07 05:23:28 +00:00 committed by GitHub
parent 9113255114
commit 5aa5cb3523
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 37 additions and 23 deletions

View file

@ -123,7 +123,8 @@ export function SessionContextTab() {
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },

View file

@ -1851,7 +1851,9 @@ export default function Page() {
const session = sdk().api.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return
const next = userMessages()[index + 1]
const last = target.session.get(sessionID)?.revert
await runPromptRollbackMutation({
@ -1891,8 +1893,10 @@ export default function Page() {
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
return userMessages()
.filter((item) => item.id >= id)
.slice(index)
.map((item) => ({ id: item.id, text: line(item.id) }))
})

View file

@ -287,7 +287,9 @@ export function MessageTimeline(props: {
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
if (!boundary) return messages
const index = messages.findIndex((message) => message.id === boundary)
return index < 0 ? messages : messages.slice(0, index)
})
const info = createMemo(() => {
const id = sessionID()

View file

@ -7,11 +7,11 @@ const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessag
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(selectVisibleUserMessages(users)).toBe(users)
})

View file

@ -104,7 +104,8 @@ export function isTimelineReady(messages: Message[] | undefined, loading: boolea
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
export async function loadOlderTimeline(input: {

View file

@ -137,11 +137,11 @@ describe("current session timeline rows", () => {
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_1", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_2",
id: "msg_a",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
@ -161,10 +161,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
"user-message:msg_z",
"turn-gap:msg_a",
"user-message:msg_a",
"thinking:msg_a",
])
})

View file

@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@ -71,12 +72,12 @@ export namespace Timeline {
turns.push(turn)
turnByUserID.set(user.id, turn)
})
const latestUserMessageID = turns.at(-1)?.user.id
projectedUserMessages.forEach((user) => {
if (turnByUserID.has(user.id)) return
if (latestUserMessageID && user.id < latestUserMessageID) return
const turn = { user, assistants: [] }
turns.push(turn)
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id

View file

@ -100,7 +100,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}
const showAllFiles = () => {
@ -337,7 +338,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
const messages = userMessages()
const message = findLast(messages, (x) => !revert || x.id < revert)
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
if (!message) return
const parts = sync().data.part[message.id]
@ -352,7 +355,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
updateViewport: () => setActiveMessage(messages[boundary - 2]),
})
}
@ -367,14 +370,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = messages.find((x) => x.id > revertMessageID)
const boundary = messages.findIndex((message) => message.id === revertMessageID)
if (boundary < 0) return
const next = messages[boundary + 1]
if (!next) {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
updateViewport: () => setActiveMessage(messages.at(-1)),
})
return
}
@ -384,7 +389,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
updateViewport: () => setActiveMessage(messages[boundary]),
})
}