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 bbbd2e9bf0
commit 98c976829c
8 changed files with 37 additions and 23 deletions
@@ -123,7 +123,8 @@ export function SessionContextTab() {
() => { () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() 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, emptyUserMessages,
{ equals: same }, { equals: same },
+6 -2
View File
@@ -1851,7 +1851,9 @@ export default function Page() {
const session = sdk().api.session const session = sdk().api.session
const target = sync() 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 const last = target.session.get(sessionID)?.revert
await runPromptRollbackMutation({ await runPromptRollbackMutation({
@@ -1891,8 +1893,10 @@ export default function Page() {
const rolled = createMemo(() => { const rolled = createMemo(() => {
const id = revertMessageID() const id = revertMessageID()
if (!id) return [] if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
return userMessages() return userMessages()
.filter((item) => item.id >= id) .slice(index)
.map((item) => ({ id: item.id, text: line(item.id) })) .map((item) => ({ id: item.id, text: line(item.id) }))
}) })
@@ -287,7 +287,9 @@ export function MessageTimeline(props: {
const visible = new Set(props.userMessages.map((message) => message.id)) const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[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 info = createMemo(() => {
const id = sessionID() const id = sessionID()
@@ -7,11 +7,11 @@ const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessag
describe("timeline model", () => { describe("timeline model", () => {
test("selects users and applies the revert boundary", () => { 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) const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"]) expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"]) expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(selectVisibleUserMessages(users)).toBe(users) expect(selectVisibleUserMessages(users)).toBe(users)
}) })
@@ -104,7 +104,8 @@ export function isTimelineReady(messages: Message[] | undefined, loading: boolea
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) { export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages 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: { export async function loadOlderTimeline(input: {
@@ -137,11 +137,11 @@ describe("current session timeline rows", () => {
test("renders an optimistic user turn and thinking before the protocol message arrives", () => { test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [ const source = [
{ id: "msg_1", type: "user", text: "existing", time: { created: 1 } }, { id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
] satisfies SessionMessageInfo[] ] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source) const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = { const optimistic = {
id: "msg_2", id: "msg_a",
sessionID: "ses_1", sessionID: "ses_1",
role: "user" as const, role: "user" as const,
time: { created: 2 }, time: { created: 2 },
@@ -161,10 +161,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe(optimistic.id) expect(result.activeMessageID).toBe(optimistic.id)
expect(result.rows.map(TimelineRow.key)).toEqual([ expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1", "user-message:msg_z",
"turn-gap:msg_2", "turn-gap:msg_a",
"user-message:msg_2", "user-message:msg_a",
"thinking:msg_2", "thinking:msg_a",
]) ])
}) })
@@ -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 { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row" import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs" import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
export { TimelineRow, type SummaryDiff } from "./timeline-row" export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -71,12 +72,12 @@ export namespace Timeline {
turns.push(turn) turns.push(turn)
turnByUserID.set(user.id, turn) turnByUserID.set(user.id, turn)
}) })
const latestUserMessageID = turns.at(-1)?.user.id
projectedUserMessages.forEach((user) => { projectedUserMessages.forEach((user) => {
if (turnByUserID.has(user.id)) return if (turnByUserID.has(user.id)) return
if (latestUserMessageID && user.id < latestUserMessageID) return
const turn = { user, assistants: [] } 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) turnByUserID.set(user.id, turn)
}) })
const activeMessageID = turns.at(-1)?.user.id const activeMessageID = turns.at(-1)?.user.id
@@ -100,7 +100,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const visibleUserMessages = () => { const visibleUserMessages = () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() 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 = () => { const showAllFiles = () => {
@@ -337,7 +338,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const promptSession = prompt.capture() const promptSession = prompt.capture()
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
const messages = userMessages() 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 if (!message) return
const parts = sync().data.part[message.id] const parts = sync().data.part[message.id]
@@ -352,7 +355,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
updatePrompt: (promptSession) => { updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) 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 const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return 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) { if (!next) {
await runCommand({ await runCommand({
owner, owner,
prompt: promptSession, prompt: promptSession,
request: () => session.revert.clear({ sessionID }), request: () => session.revert.clear({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(), updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), updateViewport: () => setActiveMessage(messages.at(-1)),
}) })
return return
} }
@@ -384,7 +389,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt: promptSession, prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }), request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined, updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), updateViewport: () => setActiveMessage(messages[boundary]),
}) })
} }