Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .playwright-mcp/console-2026-04-08T21-48-28-345Z.log
Original file line number Diff line number Diff line change
@@ -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
Binary file added packages/app/public/ort-wasm-simd-threaded.wasm
Binary file not shown.
Binary file added packages/app/public/silero_vad_legacy.onnx
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 },
Expand Down
67 changes: 58 additions & 9 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -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,
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -1926,7 +1975,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={messagesReady()}>
<Show when={messagesReady()} fallback={<div class="h-full" />}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
Expand Down
16 changes: 12 additions & 4 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { sortMessages } from "@opencode-ai/util/message"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
Expand Down Expand Up @@ -247,7 +247,7 @@ export function MessageTimeline(props: {
const sessionMessages = createMemo(() => {
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(
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -1024,6 +1023,15 @@ export function MessageTimeline(props: {
<For each={rendered()}>
{(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 &&
Expand Down
17 changes: 8 additions & 9 deletions packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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, {
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/session/fixtures/skewed-messages.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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>,
): 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),
}
}
Loading
Loading