From 5aa5cb35235509c7bcb206179cf29ee11627276e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:23:28 +0000 Subject: [PATCH] fix(app): use chronological message boundaries (#41006) Co-authored-by: Dax --- .../components/session/session-context-tab.tsx | 3 ++- packages/app/src/pages/session.tsx | 8 ++++++-- .../pages/session/timeline/message-timeline.tsx | 4 +++- .../src/pages/session/timeline/model.test.ts | 6 +++--- .../app/src/pages/session/timeline/model.ts | 3 ++- .../pages/session/timeline/rows-current.test.ts | 12 ++++++------ packages/app/src/pages/session/timeline/rows.ts | 7 ++++--- .../src/pages/session/use-session-commands.tsx | 17 +++++++++++------ 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 2f37c33fb37..a0758f3eaca 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -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 }, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4de1446cdff..2e647e0f478 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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) })) }) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index e69623179fa..25abdc9e734 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -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() diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 24612072c3d..61bff715937 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -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) }) diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 7eebee60807..33eb30093a1 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -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: { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index a5952a5e663..158897ba5ea 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -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", ]) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 879646e86a9..1f9472a0a58 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -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 diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index cfc302e7753..c4c8c44a99f 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -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]), }) }