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) } +}