Skip to content
Closed
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
18 changes: 13 additions & 5 deletions packages/app/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const messagePageSize = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightDiffVersion = new Map<string, number>()
const inflightTodo = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
Expand Down Expand Up @@ -272,18 +273,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({

return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
async diff(sessionID: string) {
async diff(sessionID: string, input?: { refresh?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.session_diff[sessionID] !== undefined) return
if (!input?.refresh && store.session_diff[sessionID] !== undefined) return

const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
const version = (inflightDiffVersion.get(key) ?? 0) + 1
inflightDiffVersion.set(key, version)

const fetch = () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (inflightDiffVersion.get(key) !== version) return
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}),
)
})

if (input?.refresh) return fetch()

return runInflight(inflightDiff, key, fetch)
},
async todo(sessionID: string) {
const directory = sdk.directory
Expand Down
66 changes: 64 additions & 2 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { showToast } from "@opencode-ai/ui/toast"

import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
Expand All @@ -31,6 +32,7 @@ import { SessionComposerRegion, createSessionComposerState } from "@/pages/sessi
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { extractPromptFromParts } from "@/utils/prompt"

export default function Page() {
const layout = useLayout()
Expand Down Expand Up @@ -157,6 +159,8 @@ export default function Page() {
})

const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
Expand Down Expand Up @@ -187,8 +191,11 @@ export default function Page() {
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const messages = userMessages()
if (!revert) return messages
const index = messages.findIndex((message) => message.id === revert)
if (index < 0) return messages
return messages.slice(0, index)
},
emptyUserMessages,
{
Expand Down Expand Up @@ -497,6 +504,59 @@ export default function Page() {
focusInput,
})

const restore = (messageID: string) =>
extractPromptFromParts(sync.data.part[messageID] ?? [], {
directory: sdk.directory,
attachmentName: language.t("common.attachment"),
})

const fail = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
showToast({ title: language.t("common.requestFailed"), description: message })
}

const onForkFromMessage = async (messageID: string) => {
const sessionID = params.id
if (!sessionID) return

const restored = restore(messageID)
const forked = await sdk.client.session
.fork({ sessionID, messageID })
.then((result) => result.data)
.catch((error: unknown) => {
fail(error)
return
})
if (!forked) return

navigate(`/${base64Encode(sdk.directory)}/session/${forked.id}`)
requestAnimationFrame(() => prompt.set(restored))
}

const onRevertToMessage = async (messageID: string) => {
const sessionID = params.id
if (!sessionID) return

if (status().type !== "idle") await sdk.client.session.abort({ sessionID }).catch(() => {})

const restored = restore(messageID)
const done = await sdk.client.session
.revert({ sessionID, messageID })
.then(() => true)
.catch((error: unknown) => {
fail(error)
return false
})
if (!done) return

void sync.session.diff(sessionID, { refresh: true })

prompt.set(restored)
const messages = userMessages()
const index = messages.findIndex((message) => message.id === messageID)
setActiveMessage(index > 0 ? messages[index - 1] : undefined)
}

const openReviewFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
Expand Down Expand Up @@ -1100,6 +1160,8 @@ export default function Page() {
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
lastUserMessageID={lastUserMessage()?.id}
onForkFromMessage={onForkFromMessage}
onRevertToMessage={onRevertToMessage}
/>
</Show>
</Match>
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export function MessageTimeline(props: {
onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void
lastUserMessageID?: string
onForkFromMessage: (messageID: string) => void
onRevertToMessage: (messageID: string) => void
}) {
let touchGesture: number | undefined

Expand Down Expand Up @@ -602,6 +604,8 @@ export function MessageTimeline(props: {
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
onForkFromMessage={props.onForkFromMessage}
onRevertToMessage={props.onRevertToMessage}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
Expand Down
23 changes: 16 additions & 7 deletions packages/opencode/src/pty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}

type Subscriber = {
ws: Socket
send: (data: string | Uint8Array | ArrayBuffer) => void
}

// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
Expand Down Expand Up @@ -87,7 +92,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
subscribers: Map<unknown, Subscriber>
}

const state = Instance.state(
Expand All @@ -97,7 +102,8 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [key, sub] of session.subscribers.entries()) {
const ws = sub.ws
try {
if (ws.data === key) ws.close()
} catch {
Expand Down Expand Up @@ -170,7 +176,8 @@ export namespace Pty {
ptyProcess.onData((chunk) => {
session.cursor += chunk.length

for (const [key, ws] of session.subscribers.entries()) {
for (const [key, sub] of session.subscribers.entries()) {
const ws = sub.ws
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
Expand All @@ -182,7 +189,7 @@ export namespace Pty {
}

try {
ws.send(chunk)
sub.send(chunk)
} catch {
session.subscribers.delete(key)
}
Expand All @@ -197,7 +204,8 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const [key, ws] of session.subscribers.entries()) {
for (const [key, sub] of session.subscribers.entries()) {
const ws = sub.ws
try {
if (ws.data === key) ws.close()
} catch {
Expand Down Expand Up @@ -232,7 +240,8 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const [key, ws] of session.subscribers.entries()) {
for (const [key, sub] of session.subscribers.entries()) {
const ws = sub.ws
try {
if (ws.data === key) ws.close()
} catch {
Expand Down Expand Up @@ -272,7 +281,7 @@ export namespace Pty {

// Optionally cleanup if the key somehow exists
session.subscribers.delete(connectionKey)
session.subscribers.set(connectionKey, ws)
session.subscribers.set(connectionKey, { ws, send: ws.send.bind(ws) })

const cleanup = () => {
session.subscribers.delete(connectionKey)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export namespace Session {
const idMap = new Map<string, string>()

for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
if (input.messageID && msg.info.id === input.messageID) break
const newID = Identifier.ascending("message")
idMap.set(msg.info.id, newID)

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ export namespace MessageV2 {
.select()
.from(MessageTable)
.where(eq(MessageTable.session_id, sessionID))
.orderBy(desc(MessageTable.time_created))
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.limit(size)
.offset(offset)
.all(),
Expand Down
29 changes: 20 additions & 9 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,21 @@ export namespace SessionRevert {
}

if (revert) {
const session = await Session.get(input.sessionID)
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
await Snapshot.revert(patches)
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
const pivot = all.findIndex((msg) => msg.info.id === revert.messageID)
const preserved = all.flatMap((msg, index) => {
if (pivot < 0) return [msg]
if (index < pivot) return [msg]
if (index > pivot) return []
if (!revert.partID) return []

const part = msg.parts.findIndex((item) => item.id === revert.partID)
if (part < 0) return [msg]
return [{ ...msg, parts: msg.parts.slice(0, part) }]
})
const diffs = await SessionSummary.computeDiff({ messages: preserved })
await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, {
sessionID: input.sessionID,
Expand Down Expand Up @@ -93,20 +102,22 @@ export namespace SessionRevert {
const sessionID = session.id
const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID
const preserve = [] as MessageV2.WithParts[]
const pivot = msgs.findIndex((msg) => msg.info.id === messageID)
if (pivot < 0) {
await Session.clearRevert(sessionID)
return
}
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) {
preserve.push(msg)
for (const [index, msg] of msgs.entries()) {
if (index < pivot) {
continue
}
if (msg.info.id > messageID) {
if (index > pivot) {
remove.push(msg)
continue
}
if (session.revert.partID) {
preserve.push(msg)
target = msg
continue
}
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export const BashTool = Tool.define("bash", async () => {
let timedOut = false
let aborted = false
let exited = false
let exit: number | null = null

const kill = () => Shell.killTree(proc, { exited: () => exited })

Expand All @@ -233,8 +234,9 @@ export const BashTool = Tool.define("bash", async () => {
ctx.abort.removeEventListener("abort", abortHandler)
}

proc.once("exit", () => {
proc.once("exit", (code) => {
exited = true
exit = code
cleanup()
resolve()
})
Expand Down Expand Up @@ -264,7 +266,7 @@ export const BashTool = Tool.define("bash", async () => {
title: params.description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: proc.exitCode,
exit: exit ?? proc.exitCode,
description: params.description,
},
output,
Expand Down
Loading
Loading