From 6dd724bb201bfd04c0fba44fe56f0a2d93a668db Mon Sep 17 00:00:00 2001 From: ODonnellan Date: Mon, 23 Feb 2026 21:30:52 +0000 Subject: [PATCH 1/3] feat(session): add fork/edit and revert actions with reliable revert diffs --- packages/app/src/context/sync.tsx | 18 +++- packages/app/src/pages/session.tsx | 66 ++++++++++++- .../src/pages/session/message-timeline.tsx | 4 + packages/opencode/src/session/index.ts | 2 +- packages/opencode/src/session/message-v2.ts | 2 +- packages/opencode/src/session/revert.ts | 29 ++++-- packages/ui/src/components/message-part.tsx | 95 ++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 10 +- packages/ui/src/i18n/ar.ts | 3 + packages/ui/src/i18n/br.ts | 3 + packages/ui/src/i18n/bs.ts | 3 + packages/ui/src/i18n/da.ts | 3 + packages/ui/src/i18n/de.ts | 3 + packages/ui/src/i18n/en.ts | 3 + packages/ui/src/i18n/es.ts | 3 + packages/ui/src/i18n/fr.ts | 3 + packages/ui/src/i18n/ja.ts | 3 + packages/ui/src/i18n/ko.ts | 3 + packages/ui/src/i18n/no.ts | 3 + packages/ui/src/i18n/pl.ts | 3 + packages/ui/src/i18n/ru.ts | 3 + packages/ui/src/i18n/th.ts | 3 + packages/ui/src/i18n/zh.ts | 3 + packages/ui/src/i18n/zht.ts | 3 + 24 files changed, 252 insertions(+), 22 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 60888b1a6fd9..57a452a7f349 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -108,6 +108,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const messagePageSize = 400 const inflight = new Map>() const inflightDiff = new Map>() + const inflightDiffVersion = new Map() const inflightTodo = new Map>() const [meta, setMeta] = createStore({ limit: {} as Record, @@ -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 diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6751f4186f7c..8669ffbc85c1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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" @@ -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() @@ -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) @@ -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, { @@ -450,6 +457,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, @@ -1043,6 +1103,8 @@ export default function Page() { onRegisterMessage={scrollSpy.register} onUnregisterMessage={scrollSpy.unregister} lastUserMessageID={lastUserMessage()?.id} + onForkFromMessage={onForkFromMessage} + onRevertToMessage={onRevertToMessage} /> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b84109035507..4de7572c4f40 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -75,6 +75,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 @@ -539,6 +541,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()} diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 22de477f8d18..ccad9151d90e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -244,7 +244,7 @@ export namespace Session { const idMap = new Map() 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) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227a..e7c8c0368f3d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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(), diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index ef9c7e2aace9..27711de65193 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -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, @@ -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 } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0f67d683f6a1..c1b6115aaf0e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -48,6 +48,8 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" +import { DropdownMenu } from "./dropdown-menu" +import { ContextMenu } from "./context-menu" interface Diagnostic { range: { @@ -94,6 +96,8 @@ export interface MessageProps { showAssistantCopyPartID?: string | null interrupted?: boolean showReasoningSummaries?: boolean + onForkFromMessage?: (messageID: string) => void + onRevertToMessage?: (messageID: string) => void } export interface MessagePartProps { @@ -488,6 +492,8 @@ export function Message(props: MessageProps) { message={userMessage() as UserMessage} parts={props.parts} interrupted={props.interrupted} + onForkFromMessage={props.onForkFromMessage} + onRevertToMessage={props.onRevertToMessage} /> )} @@ -667,7 +673,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { ) } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; interrupted?: boolean }) { +export function UserMessageDisplay(props: { + message: UserMessage + parts: PartType[] + interrupted?: boolean + onForkFromMessage?: (messageID: string) => void + onRevertToMessage?: (messageID: string) => void +}) { const data = useData() const dialog = useDialog() const i18n = useI18n() @@ -738,7 +750,54 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp setTimeout(() => setCopied(false), 2000) } - return ( + const actionable = createMemo(() => !!props.onForkFromMessage || !!props.onRevertToMessage) + + const onFork = () => props.onForkFromMessage?.(props.message.id) + const onRevert = () => props.onRevertToMessage?.(props.message.id) + + const menu = () => ( + <> + + {i18n.t("ui.message.forkAndEdit")} + + + {i18n.t("ui.message.revert")} + + + + + { + void handleCopy() + }} + > + {i18n.t("ui.message.copy")} + + + ) + + const contextMenu = () => ( + <> + + {i18n.t("ui.message.forkAndEdit")} + + + {i18n.t("ui.message.revert")} + + + + + { + void handleCopy() + }} + > + {i18n.t("ui.message.copy")} + + + ) + + const content = () => (
0}>
@@ -811,16 +870,46 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp onMouseDown={(e) => e.preventDefault()} onClick={(event) => { event.stopPropagation() - handleCopy() + void handleCopy() }} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} /> + + + + + e.preventDefault()} + onClick={(event) => event.stopPropagation()} + aria-label={i18n.t("ui.message.moreActions")} + /> + + + + {menu()} + + +
) + + return ( + + + {content()} + + {contextMenu()} + + + + ) } type HighlightSegment = { text: string; type?: "file" | "agent" } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0eceb754c81f..91904537ca40 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -142,6 +142,8 @@ export function SessionTurn( showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + onForkFromMessage?: (messageID: string) => void + onRevertToMessage?: (messageID: string) => void onUserInteracted?: () => void classes?: { root?: string @@ -361,7 +363,13 @@ export function SessionTurn( class={props.classes?.container} >
- +
0}>
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 4d79f3d001c1..b8d4602d04a0 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "نسخ الرسالة", "ui.message.copyResponse": "نسخ الرد", "ui.message.copied": "تم النسخ!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "تمت المقاطعة", "ui.message.attachment.alt": "مرفق", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 777f1455bd51..b72d6941e440 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "Copiar mensagem", "ui.message.copyResponse": "Copiar resposta", "ui.message.copied": "Copiado!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Interrompido", "ui.message.attachment.alt": "anexo", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index e499647dff3f..9aa101074f62 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -105,6 +105,9 @@ export const dict = { "ui.message.copyMessage": "Kopiraj poruku", "ui.message.copyResponse": "Kopiraj odgovor", "ui.message.copied": "Kopirano!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Prekinuto", "ui.message.attachment.alt": "prilog", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 546040598f88..2a632803f75c 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -100,6 +100,9 @@ export const dict = { "ui.message.copyMessage": "Kopier besked", "ui.message.copyResponse": "Kopier svar", "ui.message.copied": "Kopieret!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Afbrudt", "ui.message.attachment.alt": "vedhæftning", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index bf5730f85f2f..c003db802704 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -104,6 +104,9 @@ export const dict = { "ui.message.copyMessage": "Nachricht kopieren", "ui.message.copyResponse": "Antwort kopieren", "ui.message.copied": "Kopiert!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Unterbrochen", "ui.message.attachment.alt": "Anhang", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 4c9b89c6cfe8..6b2c32dec5f3 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "Copy message", "ui.message.copyResponse": "Copy response", "ui.message.copied": "Copied", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Interrupted", "ui.message.attachment.alt": "attachment", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 2f21b398f1c9..1d45f12928dd 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "Copiar mensaje", "ui.message.copyResponse": "Copiar respuesta", "ui.message.copied": "¡Copiado!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Interrumpido", "ui.message.attachment.alt": "adjunto", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index d4ea93868484..c0f2dd33de70 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "Copier le message", "ui.message.copyResponse": "Copier la réponse", "ui.message.copied": "Copié !", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Interrompu", "ui.message.attachment.alt": "pièce jointe", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 0a4366ebefda..cdd99671aa81 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -100,6 +100,9 @@ export const dict = { "ui.message.copyMessage": "メッセージをコピー", "ui.message.copyResponse": "応答をコピー", "ui.message.copied": "コピーしました!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "中断", "ui.message.attachment.alt": "添付ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 58bd51b99102..8df91dfa0de5 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -101,6 +101,9 @@ export const dict = { "ui.message.copyMessage": "메시지 복사", "ui.message.copyResponse": "응답 복사", "ui.message.copied": "복사됨!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "중단됨", "ui.message.attachment.alt": "첨부 파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index b7e604f9acb6..1f6b7908a03b 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -104,6 +104,9 @@ export const dict: Record = { "ui.message.copyMessage": "Kopier melding", "ui.message.copyResponse": "Kopier svar", "ui.message.copied": "Kopiert!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Avbrutt", "ui.message.attachment.alt": "vedlegg", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fbccb92207c3..248283c2e106 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -100,6 +100,9 @@ export const dict = { "ui.message.copyMessage": "Kopiuj wiadomość", "ui.message.copyResponse": "Kopiuj odpowiedź", "ui.message.copied": "Skopiowano!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Przerwano", "ui.message.attachment.alt": "załącznik", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 705f2d21090c..52ec8284b930 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -100,6 +100,9 @@ export const dict = { "ui.message.copyMessage": "Копировать сообщение", "ui.message.copyResponse": "Копировать ответ", "ui.message.copied": "Скопировано!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "Прервано", "ui.message.attachment.alt": "вложение", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index cf536e1ff6cf..9ae85ce968ce 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -102,6 +102,9 @@ export const dict = { "ui.message.copyMessage": "คัดลอกข้อความ", "ui.message.copyResponse": "คัดลอกคำตอบ", "ui.message.copied": "คัดลอกแล้ว!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "ถูกขัดจังหวะ", "ui.message.attachment.alt": "ไฟล์แนบ", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 5d3d5613da5d..f1fbfa966963 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -105,6 +105,9 @@ export const dict = { "ui.message.copyMessage": "复制消息", "ui.message.copyResponse": "复制回复", "ui.message.copied": "已复制!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "已中断", "ui.message.attachment.alt": "附件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index b61349e25d2d..0d1691510c7d 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -105,6 +105,9 @@ export const dict = { "ui.message.copyMessage": "複製訊息", "ui.message.copyResponse": "複製回覆", "ui.message.copied": "已複製!", + "ui.message.moreActions": "Message actions", + "ui.message.forkAndEdit": "Fork and edit", + "ui.message.revert": "Revert", "ui.message.interrupted": "已中斷", "ui.message.attachment.alt": "附件", From e78a780647302161158cf3cbfce92f02676c6893 Mon Sep 17 00:00:00 2001 From: ODonnellan Date: Thu, 26 Feb 2026 22:54:16 +0000 Subject: [PATCH 2/3] fix(opencode): snapshot websocket sender in PTY subscribers --- packages/opencode/src/pty/index.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index dee3fbc54298..678e9d6a0a55 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -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 }) @@ -87,7 +92,7 @@ export namespace Pty { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map } const state = Instance.state( @@ -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 { @@ -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 @@ -182,7 +189,7 @@ export namespace Pty { } try { - ws.send(chunk) + sub.send(chunk) } catch { session.subscribers.delete(key) } @@ -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 { @@ -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 { @@ -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) From 402cad365e7593ddc482464f785c2e6377e7a05f Mon Sep 17 00:00:00 2001 From: ODonnellan Date: Thu, 26 Feb 2026 23:12:53 +0000 Subject: [PATCH 3/3] fix(opencode): capture process exit code from exit event --- packages/opencode/src/tool/bash.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0751f789b7db..ed8e97e52340 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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 }) @@ -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() }) @@ -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,