From b86ffec02fcfa163eba09db666ef8aa36715588b Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:18:05 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20edit/revert=20sent=20messages=20via=20s?= =?UTF-8?q?erver=20revert=20API=20=E2=80=94=20Closes=20#56=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: edit/revert sent messages via server revert API Wires the mobile client up to the opencode server's session.revert / session.unrevert endpoints (the same primitive the desktop TUI uses to edit the last message). Long-press a user message bubble -> "Edit message" reverts it server-side and prefills the composer with its text; a banner offers Undo while the revert is pending (it's only cleaned up server-side on the next prompt). Degrades gracefully with an alert on older servers that 404 the /revert route. Closes #56 * fix(revert): address code review findings on edit/revert message flow - Confirm before overwriting an in-progress composer draft when editing a sent message (F2) - Restore reverted message's file attachments into the composer, not just its text (F3) - Distinguish 401/403 from other revert failures with an accurate "Authentication failed" message instead of a generic one (F4) - Exclude optimistic "temp-" message IDs from the revert cutoff comparison so concurrently-sent messages aren't hidden (F9) --------- Co-authored-by: engineer --- app/session/[id].tsx | 106 +++++++++++++++++++++++++- src/components/chat/MessageBubble.tsx | 14 +++- src/lib/prompt-from-parts.test.ts | 32 ++++++++ src/lib/prompt-from-parts.ts | 20 +++++ src/lib/sdk.ts | 35 ++++++++- src/stores/sessions.ts | 57 +++++++++++++- 6 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 src/lib/prompt-from-parts.test.ts create mode 100644 src/lib/prompt-from-parts.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 8f1a328..80d07d4 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -95,6 +95,8 @@ export default function SessionScreen() { sendMessage, abortSession, loadOlderMessages, + revertToMessage, + unrevertSession, } = useSessions() // Derive sending state for this specific session @@ -156,18 +158,92 @@ export default function SessionScreen() { return [...custom, ...BUILTIN_COMMANDS] }, [serverCommands]) + // While a revert is pending, the reverted message and everything after it + // still exist server-side (cleanup only runs on the next prompt/unrevert) + // — hide them client-side so editing feels immediate. Message IDs are + // lexicographically sortable, same comparison the TUI uses. Optimistic + // "temp-" IDs (assigned client-side before the server responds, see + // sendMessage) aren't part of that sort order — always keep them so a + // message sent concurrently with a revert isn't hidden. + const revertMessageID = currentSession?.revert?.messageID + // Inverted FlatList: data is reversed (newest first) so newest renders at bottom const messageData = useMemo( () => (messages || []) + .filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID) .map((msg) => ({ message: msg, parts: (parts && parts[msg.id]) || [], })) .reverse(), - [messages, parts], + [messages, parts, revertMessageID], ) + // Tracks the latest composer text without pulling `input` into + // handleMessageLongPress's deps — kept as a plain ref assignment (not + // state) so the callback below stays referentially stable across + // keystrokes for MessageBubble's custom memo comparator. + const inputRef = useRef(input) + inputRef.current = input + + const applyRevertResult = useCallback((result: Awaited>) => { + if (!result.ok) { + if (result.reason === "unsupported") { + Alert.alert( + "Not supported", + "Editing sent messages needs a newer opencode server. Please update the server and try again.", + ) + } else if (result.reason === "auth") { + Alert.alert("Authentication failed", "Your server credentials were rejected. Check your connection and try again.") + } else { + Alert.alert("Edit failed", "Could not revert to this message. Please try again.") + } + return + } + setInput(result.text) + // Restore attachments in the same shape the composer's own picker + // functions (pickFromLibrary/pickFromCamera/pasteFromClipboard) use. + setAttachments( + result.files + .filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime) + .map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })), + ) + }, []) + + // Stable across renders (reads fresh state via getState() rather than + // closing over props) so MessageBubble's custom memo comparator can bail + // safely without risking a stale handler. + const handleMessageLongPress = useCallback((messageID: string) => { + Alert.alert("Message actions", undefined, [ + { text: "Cancel", style: "cancel" }, + { + text: "Edit message", + onPress: () => { + const doRevert = async () => { + const result = await useSessions.getState().revertToMessage(messageID) + applyRevertResult(result) + } + // Editing overwrites the composer — don't silently clobber an + // in-progress unsent draft. + if (inputRef.current.trim()) { + Alert.alert( + "Replace draft?", + "You have an unsent message in the composer. Editing this message will replace it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Replace", style: "destructive", onPress: doRevert }, + ], + { cancelable: false }, + ) + return + } + doRevert() + }, + }, + ]) + }, [applyRevertResult]) + const scrollToBottom = useCallback((animated = true) => { flatListRef.current?.scrollToOffset({ offset: 0, animated }) }, []) @@ -528,6 +604,17 @@ export default function SessionScreen() { )} + {/* Pending revert (from "Edit message") — offer a way back before it's + cleaned up by the next prompt. */} + {revertMessageID && ( + + Message reverted — resend to confirm + unrevertSession()} hitSlop={8}> + Undo + + + )} + {isLoading ? ( @@ -539,7 +626,14 @@ export default function SessionScreen() { data={messageData} inverted keyExtractor={(item) => item.message.id} - renderItem={({ item }) => } + renderItem={({ item }) => ( + + )} contentContainerStyle={s.messageList} onScroll={handleScroll} scrollEventThrottle={100} @@ -910,4 +1004,12 @@ const s = StyleSheet.create({ bannerReconnecting: { backgroundColor: "#92400e" }, bannerConnected: { backgroundColor: "#065f46" }, bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" }, + + // Pending revert (edit message) banner + bannerRevert: { + backgroundColor: "#1e3a8a", + flexDirection: "row", + justifyContent: "space-between", + }, + bannerAction: { color: "#93c5fd", fontSize: 13, fontWeight: "700" }, }) diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 97d7038..b3ee15e 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -16,12 +16,16 @@ interface Props { message: Message parts: Part[] isDark: boolean + // Only wired up for user messages — long-press opens the "Edit message" / + // revert action sheet. Identified by messageID (not a closure over parts) + // so it stays correct even if the memo below bails on a stale render. + onLongPress?: (messageID: string) => void } // TODO: Replace with streamdown-rn once React 19 types PR lands - it has // built-in block-level memoization that eliminates re-renders for stable blocks export const MessageBubble = memo( - function MessageBubble({ message, parts, isDark }: Props) { + function MessageBubble({ message, parts, isDark, onLongPress }: Props) { const isUser = message.role === "user" const textParts = parts.filter((p) => p.type === "text") @@ -32,7 +36,10 @@ export const MessageBubble = memo( const reasoning = reasoningParts.map((p) => p.text).join("\n") || "" return ( - onLongPress(message.id) : undefined} + disabled={!isUser || !onLongPress} style={[ s.bubble, isUser ? s.user : s.assistant, @@ -101,7 +108,7 @@ export const MessageBubble = memo( {message.cost ? ` · $${message.cost.toFixed(4)}` : ""} )} - + ) }, (prev, next) => { @@ -109,6 +116,7 @@ export const MessageBubble = memo( // This prevents completed messages from re-rendering during streaming if (prev.message.id !== next.message.id) return false if (prev.isDark !== next.isDark) return false + if (prev.onLongPress !== next.onLongPress) return false if (prev.parts.length !== next.parts.length) return false // Compare the last part's text content - this is what changes during streaming const prevLast = prev.parts[prev.parts.length - 1] diff --git a/src/lib/prompt-from-parts.test.ts b/src/lib/prompt-from-parts.test.ts new file mode 100644 index 0000000..de76647 --- /dev/null +++ b/src/lib/prompt-from-parts.test.ts @@ -0,0 +1,32 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { extractPromptFromParts } from "./prompt-from-parts.ts" +import type { Part } from "./sdk.ts" + +test("extractPromptFromParts: joins multiple text parts with newlines", () => { + const parts: Part[] = [ + { id: "p1", messageID: "m1", type: "text", text: "hello" }, + { id: "p2", messageID: "m1", type: "text", text: "world" }, + ] + assert.deepEqual(extractPromptFromParts(parts), { text: "hello\nworld", files: [] }) +}) + +test("extractPromptFromParts: collects file parts separately from text", () => { + const filePart: Part = { id: "p2", messageID: "m1", type: "file", mime: "image/jpeg", url: "data:..." } + const parts: Part[] = [{ id: "p1", messageID: "m1", type: "text", text: "check this" }, filePart] + assert.deepEqual(extractPromptFromParts(parts), { text: "check this", files: [filePart] }) +}) + +test("extractPromptFromParts: ignores non-text/file parts (tool, reasoning)", () => { + const parts: Part[] = [ + { id: "p1", messageID: "m1", type: "reasoning", text: "thinking..." }, + { id: "p2", messageID: "m1", type: "tool", tool: "bash" }, + { id: "p3", messageID: "m1", type: "text", text: "final answer" }, + ] + assert.deepEqual(extractPromptFromParts(parts), { text: "final answer", files: [] }) +}) + +test("extractPromptFromParts: handles undefined/empty input", () => { + assert.deepEqual(extractPromptFromParts(undefined), { text: "", files: [] }) + assert.deepEqual(extractPromptFromParts([]), { text: "", files: [] }) +}) diff --git a/src/lib/prompt-from-parts.ts b/src/lib/prompt-from-parts.ts new file mode 100644 index 0000000..a9a7b71 --- /dev/null +++ b/src/lib/prompt-from-parts.ts @@ -0,0 +1,20 @@ +// Pure helper: turn a reverted message's parts back into editable prompt +// state (text + file attachments), mirroring the TUI's revert-to-edit +// reduce (packages/tui/src/routes/session/dialog-message.tsx). Kept +// dependency-free so it's testable under plain `node --test`. +import type { Part } from "./sdk" + +export interface PromptFromParts { + text: string + files: Part[] +} + +export function extractPromptFromParts(parts: Part[] | undefined): PromptFromParts { + const list = parts || [] + const text = list + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + const files = list.filter((p) => p.type === "file") + return { text, files } +} diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index b26571c..b451269 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -35,6 +35,13 @@ export interface Session { deletions: number files: number } + // Present while a message (and everything after it) is pending revert — + // the server keeps the underlying messages until the next prompt/summarize + // call runs cleanup (or the revert is undone via session.unrevert). + revert?: { + messageID: string + partID?: string + } } export interface Message { @@ -163,6 +170,18 @@ export interface HealthResponse { const REQUEST_TIMEOUT_MS = 30_000 +// Thrown by request() on a non-2xx response. Carries the HTTP status so +// callers can distinguish e.g. 404 (older server, endpoint missing) from +// other failures without parsing the message string. +export class ApiError extends Error { + status: number + constructor(status: number, body: string) { + super(`API Error: ${status} - ${body}`) + this.name = "ApiError" + this.status = status + } +} + function createHeaders(config: ClientConfig): HeadersInit { return buildRequestHeaders(config) } @@ -177,7 +196,7 @@ async function request(config: ClientConfig, path: string, options: RequestIn if (!response.ok) { const error = await response.text() - throw new Error(`API Error: ${response.status} - ${error}`) + throw new ApiError(response.status, error) } return response.json() @@ -365,6 +384,20 @@ export function createClient(config: ClientConfig) { const qs = messageID ? `?messageID=${messageID}` : "" return request(config, `/session/${sessionID}/diff${qs}`) }, + + // Marks messageID (and everything after it) as pending revert. The + // underlying messages aren't deleted until the next prompt runs + // cleanup, or the revert is undone with unrevert() below. + revert: (sessionID: string, messageID: string, partID?: string) => + request(config, `/session/${sessionID}/revert`, { + method: "POST", + body: JSON.stringify(partID ? { messageID, partID } : { messageID }), + }), + + unrevert: (sessionID: string) => + request(config, `/session/${sessionID}/unrevert`, { + method: "POST", + }), }, permission: { diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 0f34c00..e9a2bbd 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -1,9 +1,10 @@ import { create } from "zustand" -import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk" +import { ApiError, type Session, type Message, type Part, type Event, type MessageWithParts, type Client } from "../lib/sdk" import { useConnections } from "./connections" import { useSettings } from "./settings" import { addBreadcrumb } from "../lib/sentry" import { AnalyticsEvent, track } from "../lib/analytics" +import { extractPromptFromParts, type PromptFromParts } from "../lib/prompt-from-parts" // Helper to convert API response to our internal format function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record } { @@ -50,10 +51,16 @@ interface SessionsState { abortSession: () => Promise refreshMessages: () => Promise + // Revert (edit sent message) / unrevert (undo the pending revert) + revertToMessage: (messageID: string) => Promise + unrevertSession: () => Promise + // Event handling handleEvent: (event: Event) => void } +export type RevertResult = ({ ok: true } & PromptFromParts) | { ok: false; reason: "unsupported" | "auth" | "error" } + // Sessions the user aborted since they last went busy. Mirrors events.ts's // erroredSessions: SessionStatus has no "aborted" variant — an aborted run // still ends with a busy -> idle transition — so without this mark a @@ -341,6 +348,54 @@ export const useSessions = create((set, get) => ({ } }, + // Marks messageID (and everything after it) as pending revert, so the + // user can re-edit and resend it. The server keeps the underlying + // messages until the next prompt runs cleanup, or unrevertSession() below + // undoes it — so this only flips session.revert, it doesn't delete + // anything itself. Returns the reverted message's text/files so the + // caller can prefill the composer. + revertToMessage: async (messageID) => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) return { ok: false, reason: "error" } + + try { + const updated = await client.session.revert(session.id, messageID) + set((state) => ({ + currentSession: state.currentSession?.id === session.id ? updated : state.currentSession, + })) + return { ok: true, ...extractPromptFromParts(get().parts[messageID]) } + } catch (err) { + if (err instanceof ApiError) { + // Older servers (pre session.revert) 404 on this route — degrade + // gracefully instead of surfacing a generic error. + if (err.status === 404) return { ok: false, reason: "unsupported" } + // Expired/invalid credentials — distinct from a generic failure so + // the caller can point the user at reconnecting rather than "retry". + if (err.status === 401 || err.status === 403) return { ok: false, reason: "auth" } + } + console.error("Failed to revert message:", err) + set({ error: "Failed to revert message" }) + return { ok: false, reason: "error" } + } + }, + + unrevertSession: async () => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) return + + try { + const updated = await client.session.unrevert(session.id) + set((state) => ({ + currentSession: state.currentSession?.id === session.id ? updated : state.currentSession, + })) + } catch (err) { + console.error("Failed to unrevert session:", err) + set({ error: "Failed to restore reverted messages" }) + } + }, + handleEvent: (event) => { const { currentSession } = get() if (!currentSession) return