feat: edit/revert sent messages via server revert API — Closes #56 (#80)

* 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 <engineer@gray-knight-m1.local>
This commit is contained in:
Den
2026-07-17 02:18:05 -07:00
committed by GitHub
parent 0fdfb54d9d
commit b86ffec02f
6 changed files with 257 additions and 7 deletions

View File

@@ -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 (
<View
<TouchableOpacity
activeOpacity={isUser && onLongPress ? 0.7 : 1}
onLongPress={isUser && onLongPress ? () => 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)}` : ""}
</Text>
)}
</View>
</TouchableOpacity>
)
},
(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]