diff --git a/.playwright-mcp/console-2026-04-08T21-48-28-345Z.log b/.playwright-mcp/console-2026-04-08T21-48-28-345Z.log
new file mode 100644
index 000000000000..a8c15de5b0cc
--- /dev/null
+++ b/.playwright-mcp/console-2026-04-08T21-48-28-345Z.log
@@ -0,0 +1,9 @@
+[ 1040ms] [ERROR] Executing inline script violates the following Content Security Policy directive 'script-src 'self' 'wasm-unsafe-eval''. Either the 'unsafe-inline' keyword, a hash ('sha256-QI23YWMJrD/tljM6/82tpL8EwqdBoptwZfycFHA9IiQ='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked. @ http://127.0.0.1:18199/L2hvbWUvdWJ1bnR1L29wZW5jb2RlL2RlZmF1bHQ/session/ses_smoke_skew_live:15
+[ 2771ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:18199/session/ses_smoke_skew_live?directory=%2Fhome%2Fubuntu%2Fopencode%2Fdefault:0
+[ 2789ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:18199/session/ses_smoke_skew_live/message?limit=80&directory=%2Fhome%2Fubuntu%2Fopencode%2Fdefault:0
+[ 2900ms] Object
+[ 2900ms] Object
+[ 4036ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:18199/session/ses_smoke_skew_live?directory=%2Fhome%2Fubuntu%2Fopencode%2Fdefault:0
+[ 4037ms] Object
+[ 4037ms] Object
+[ 4039ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:18199/session/ses_smoke_skew_live/message?limit=80&directory=%2Fhome%2Fubuntu%2Fopencode%2Fdefault:0
diff --git a/packages/app/public/ort-wasm-simd-threaded.wasm b/packages/app/public/ort-wasm-simd-threaded.wasm
new file mode 100644
index 000000000000..f21ee10a4c63
Binary files /dev/null and b/packages/app/public/ort-wasm-simd-threaded.wasm differ
diff --git a/packages/app/public/silero_vad_legacy.onnx b/packages/app/public/silero_vad_legacy.onnx
new file mode 100644
index 000000000000..e6db48d6e2a0
Binary files /dev/null and b/packages/app/public/silero_vad_legacy.onnx differ
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 4e7dc8e783c1..f734b626681d 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -3,6 +3,7 @@ import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
+import { sortMessages } from "@opencode-ai/util/message"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
@@ -102,7 +103,7 @@ export function SessionContextTab() {
() => {
const id = params.id
if (!id) return emptyMessages
- return (sync.data.message[id] ?? []) as Message[]
+ return sortMessages((sync.data.message[id] ?? []) as Message[])
},
emptyMessages,
{ equals: same },
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index eb6a49411955..853c0e42085a 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -346,6 +346,8 @@ export default function Page() {
const [ui, setUi] = createStore({
pendingMessage: undefined as string | undefined,
+ restoring: undefined as string | undefined,
+ reverting: false,
reviewSnap: false,
scrollGesture: 0,
scroll: {
@@ -448,7 +450,11 @@ export default function Page() {
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
- const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+ const messages = createMemo(() => {
+ const id = params.id
+ if (!id) return []
+ return sync.data.message[id] ?? []
+ })
const messagesReady = createMemo(() => {
const id = params.id
if (!id) return true
@@ -473,7 +479,8 @@ export default function Page() {
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
- return userMessages().filter((m) => m.id < revert)
+ const idx = userMessages().findIndex((m) => m.id === revert)
+ return idx >= 0 ? userMessages().slice(0, idx) : userMessages()
},
emptyUserMessages,
{
@@ -757,7 +764,7 @@ export default function Page() {
)
return
}
- const at = list.findIndex((item) => item.id > next.id)
+ const at = list.findIndex((item) => item.id.localeCompare(next.id) > 0)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
@@ -1785,16 +1792,58 @@ export default function Page() {
}
const restore = (id: string) => {
- if (!params.id || reverting()) return
- return restoreMutation.mutateAsync(id)
+ const sessionID = params.id
+ if (!sessionID || ui.restoring || ui.reverting) return
+
+ const idx = userMessages().findIndex((m) => m.id === id)
+ const next = idx >= 0 ? userMessages()[idx + 1] : undefined
+ const prev = prompt.current().slice()
+ const last = info()?.revert
+
+ batch(() => {
+ setUi("restoring", id)
+ setUi("reverting", true)
+ roll(sessionID, next ? { messageID: next.id } : undefined)
+ if (next) {
+ prompt.set(draft(next.id))
+ return
+ }
+ prompt.reset()
+ })
+
+ const task = !next
+ ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
+ : halt(sessionID).then(() =>
+ sdk.client.session.revert({
+ sessionID,
+ messageID: next.id,
+ }),
+ )
+
+ return task
+ .then((result) => {
+ if (result.data) merge(result.data)
+ })
+ .catch((err) => {
+ batch(() => {
+ roll(sessionID, last)
+ prompt.set(prev)
+ })
+ fail(err)
+ })
+ .finally(() => {
+ batch(() => {
+ setUi("restoring", (value) => (value === id ? undefined : value))
+ setUi("reverting", false)
+ })
+ })
}
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
- return userMessages()
- .filter((item) => item.id >= id)
- .map((item) => ({ id: item.id, text: line(item.id) }))
+ const idx = userMessages().findIndex((m) => m.id === id)
+ return (idx >= 0 ? userMessages().slice(idx) : []).map((item) => ({ id: item.id, text: line(item.id) }))
})
const actions = { revert }
@@ -1926,7 +1975,7 @@ export default function Page() {
-
+ }>
{
const id = sessionID()
if (!id) return emptyMessages
- return sync.data.message[id] ?? emptyMessages
+ return sortMessages(sync.data.message[id] ?? emptyMessages)
})
const pending = createMemo(() =>
sessionMessages().findLast(
@@ -281,8 +281,7 @@ export function MessageTimeline(props: {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
- const result = Binary.search(messages, parentID, (message) => message.id)
- const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
+ const message = messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
}
@@ -1024,6 +1023,15 @@ export function MessageTimeline(props: {
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
+ const queued = createMemo(() => {
+ if (active()) return false
+ const activeID = activeMessageID()
+ if (!activeID) return false
+ const ids = rendered()
+ const activeIdx = ids.indexOf(activeID)
+ if (activeIdx === -1) return false
+ return ids.indexOf(messageID) > activeIdx
+ })
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) =>
a.length === b.length &&
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 2397953737c7..4f3382497fc6 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -1,4 +1,5 @@
import { useNavigate } from "@solidjs/router"
+import { createMemo } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -14,6 +15,7 @@ import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
+import { sortMessages } from "@opencode-ai/util/message"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -68,18 +70,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const closableTab = tabState.closableTab
const idle = { type: "idle" as const }
- const status = () => sync.data.session_status[params.id ?? ""] ?? idle
- const messages = () => {
- const id = params.id
- if (!id) return []
- return sync.data.message[id] ?? []
- }
- const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
- const visibleUserMessages = () => {
+ const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
+ const messages = createMemo(() => sortMessages(params.id ? (sync.data.message[params.id] ?? []) : []))
+ const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
+ const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
- }
+ })
+
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts
index 9df3f36eb8c7..f6201679c2fb 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -75,7 +75,8 @@ export namespace SessionRevert {
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
- const range = all.filter((msg) => msg.info.id >= rev!.messageID)
+ const idx = all.findIndex((msg) => msg.info.id === rev!.messageID)
+ const range = idx >= 0 ? all.slice(idx) : all
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
@@ -108,17 +109,21 @@ export namespace SessionRevert {
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
- for (const msg of msgs) {
- if (msg.info.id < messageID) continue
- if (msg.info.id > messageID) {
+ const idx = msgs.findIndex((msg) => msg.info.id === messageID)
+ if (idx >= 0) {
+ for (let i = 0; i < msgs.length; i++) {
+ const msg = msgs[i]!
+ if (i < idx) continue
+ if (i > idx) {
+ remove.push(msg)
+ continue
+ }
+ if (session.revert.partID) {
+ target = msg
+ continue
+ }
remove.push(msg)
- continue
- }
- if (session.revert.partID) {
- target = msg
- continue
}
- remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
diff --git a/packages/opencode/test/session/fixtures/skewed-messages.ts b/packages/opencode/test/session/fixtures/skewed-messages.ts
new file mode 100644
index 000000000000..e30b4f95ec39
--- /dev/null
+++ b/packages/opencode/test/session/fixtures/skewed-messages.ts
@@ -0,0 +1,84 @@
+import { Identifier } from "../../../src/id/id"
+import { MessageV2 } from "../../../src/session/message-v2"
+import { MessageID, SessionID } from "../../../src/session/schema"
+import { ModelID, ProviderID } from "../../../src/provider/schema"
+
+export function makeUser(
+ id: MessageID,
+ opts?: Partial,
+): MessageV2.User {
+ return {
+ id,
+ sessionID: SessionID.make("test-session"),
+ role: "user",
+ time: { created: Date.now() },
+ agent: "default",
+ model: {
+ providerID: ProviderID.openai,
+ modelID: ModelID.make("gpt-4"),
+ },
+ ...opts,
+ }
+}
+
+export function makeAssistant(
+ id: MessageID,
+ parentID: MessageID,
+ opts?: Partial,
+): MessageV2.Assistant {
+ return {
+ id,
+ sessionID: SessionID.make("test-session"),
+ role: "assistant",
+ parentID,
+ time: { created: Date.now() },
+ modelID: ModelID.make("gpt-4"),
+ providerID: ProviderID.openai,
+ mode: "default",
+ agent: "default",
+ path: {
+ cwd: "/tmp",
+ root: "/tmp",
+ },
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ finish: "stop",
+ ...opts,
+ }
+}
+
+export function aheadPair(): {
+ user: MessageV2.User
+ assistant: MessageV2.Assistant
+} {
+ const now = Date.now()
+ const userID = MessageID.make(Identifier.create("message", false, now + 60_000))
+ const assistantID = MessageID.make(Identifier.create("message", false, now))
+
+ return {
+ user: makeUser(userID),
+ assistant: makeAssistant(assistantID, userID),
+ }
+}
+
+export function behindPair(): {
+ user: MessageV2.User
+ assistant: MessageV2.Assistant
+} {
+ const now = Date.now()
+ const userID = MessageID.make(Identifier.create("message", false, now - 60_000))
+ const assistantID = MessageID.make(Identifier.create("message", false, now))
+
+ return {
+ user: makeUser(userID),
+ assistant: makeAssistant(assistantID, userID),
+ }
+}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index e891a6febe2f..e7ca16732c0c 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -8,7 +8,7 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
-import { Binary } from "@opencode-ai/util/binary"
+import { sortMessages, selectAssistants } from "@opencode-ai/util/message"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
@@ -154,6 +154,7 @@ export function SessionTurn(
editToolDefaultOpen?: boolean
active?: boolean
status?: SessionStatus
+ queued?: boolean
onUserInteracted?: () => void
classes?: {
root?: string
@@ -172,13 +173,11 @@ export function SessionTurn(
const emptyDiffs: SnapshotFileDiff[] = []
const idle = { type: "idle" as const }
- const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
+ const allMessages = createMemo(() => props.messages ?? sortMessages(list(data.store.message?.[props.sessionID], emptyMessages)))
const messageIndex = createMemo(() => {
- const messages = allMessages() ?? emptyMessages
- const result = Binary.search(messages, props.messageID, (m) => m.id)
-
- const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID)
+ const messages = allMessages()
+ const index = messages.findIndex((m) => m.id === props.messageID)
if (index < 0) return -1
const msg = messages[index]
@@ -209,9 +208,8 @@ export function SessionTurn(
const pendingUser = createMemo(() => {
const item = pending()
if (!item?.parentID) return
- const messages = allMessages() ?? emptyMessages
- const result = Binary.search(messages, item.parentID, (m) => m.id)
- const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
+ const messages = allMessages()
+ const msg = messages.find((m) => m.id === item.parentID)
if (!msg || msg.role !== "user") return
return msg
})
@@ -224,6 +222,17 @@ export function SessionTurn(
return parent.id === msg.id
})
+ const queued = createMemo(() => {
+ if (typeof props.queued === "boolean") return props.queued
+ const index = messageIndex()
+ if (index < 0) return false
+ if (!pendingUser()) return false
+ const item = pending()
+ if (!item) return false
+ const messages = allMessages()
+ const active = messages.findIndex((m) => m.id === item.parentID)
+ return active >= 0 && index > active
+ })
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@@ -265,19 +274,7 @@ export function SessionTurn(
() => {
const msg = message()
if (!msg) return emptyAssistant
-
- const messages = allMessages() ?? emptyMessages
- const index = messageIndex()
- if (index < 0) return emptyAssistant
-
- const result: AssistantMessage[] = []
- for (let i = index + 1; i < messages.length; i++) {
- const item = messages[i]
- if (!item) continue
- if (item.role === "user") break
- if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
- }
- return result
+ return selectAssistants(allMessages(), msg.id) as AssistantMessage[]
},
emptyAssistant,
{ equals: same },
diff --git a/packages/util/src/message.test.ts b/packages/util/src/message.test.ts
new file mode 100644
index 000000000000..7ab17c4b6b7a
--- /dev/null
+++ b/packages/util/src/message.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { selectAssistants, sortMessages, splitMessages } from "./message"
+
+describe("message", () => {
+ test("sortMessages uses created time before id", () => {
+ const result = sortMessages([
+ { id: "msg_z", role: "assistant", time: { created: 20 } },
+ { id: "msg_a", role: "user", time: { created: 10 } },
+ ])
+ expect(result.map((item) => item.id)).toEqual(["msg_a", "msg_z"])
+ })
+
+ test("selectAssistants finds replies even when assistant id sorts before user id", () => {
+ const result = selectAssistants(
+ [
+ { id: "msg_user", role: "user", time: { created: 10 } },
+ { id: "msg_assistant", role: "assistant", parentID: "msg_user", time: { created: 11 } },
+ ],
+ "msg_user",
+ )
+ expect(result.map((item) => item.id)).toEqual(["msg_assistant"])
+ })
+
+ test("splitMessages uses chronological order instead of id order", () => {
+ const result = splitMessages(
+ [
+ { id: "msg_3", role: "user", time: { created: 30 } },
+ { id: "msg_1", role: "user", time: { created: 10 } },
+ { id: "msg_2", role: "user", time: { created: 20 } },
+ ],
+ "msg_2",
+ )
+ expect(result.before.map((item) => item.id)).toEqual(["msg_1"])
+ expect(result.after.map((item) => item.id)).toEqual(["msg_2", "msg_3"])
+ })
+})
diff --git a/packages/util/src/message.ts b/packages/util/src/message.ts
new file mode 100644
index 000000000000..1b013b9f99f0
--- /dev/null
+++ b/packages/util/src/message.ts
@@ -0,0 +1,44 @@
+type Message = {
+ id: string
+ role?: string
+ parentID?: string
+ time?: {
+ created?: number
+ }
+}
+
+function rank(message: Message) {
+ if (message.role === "user") return 0
+ if (message.role === "assistant") return 1
+ return 2
+}
+
+export function compareMessages(a: Message, b: Message) {
+ const at = a.time?.created ?? 0
+ const bt = b.time?.created ?? 0
+ if (at !== bt) return at - bt
+
+ const ar = rank(a)
+ const br = rank(b)
+ if (ar !== br) return ar - br
+
+ if (a.id < b.id) return -1
+ if (a.id > b.id) return 1
+ return 0
+}
+
+export function sortMessages(messages: readonly T[]) {
+ return messages.slice().sort(compareMessages)
+}
+
+export function selectAssistants(messages: readonly T[], parentID: string) {
+ return sortMessages(messages.filter((message) => message.role === "assistant" && message.parentID === parentID))
+}
+
+export function splitMessages(messages: readonly T[], markerID?: string) {
+ const sorted = sortMessages(messages)
+ if (!markerID) return { before: sorted, after: [] as T[] }
+ const index = sorted.findIndex((message) => message.id === markerID)
+ if (index === -1) return { before: sorted, after: [] as T[] }
+ return { before: sorted.slice(0, index), after: sorted.slice(index) }
+}