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

@@ -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<ReturnType<typeof revertToMessage>>) => {
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() {
</View>
)}
{/* Pending revert (from "Edit message") — offer a way back before it's
cleaned up by the next prompt. */}
{revertMessageID && (
<View style={[s.banner, s.bannerRevert]}>
<Text style={s.bannerText}>Message reverted — resend to confirm</Text>
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
<Text style={s.bannerAction}>Undo</Text>
</TouchableOpacity>
</View>
)}
{isLoading ? (
<View style={s.loading}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
@@ -539,7 +626,14 @@ export default function SessionScreen() {
data={messageData}
inverted
keyExtractor={(item) => item.message.id}
renderItem={({ item }) => <MessageBubble message={item.message} parts={item.parts} isDark={isDark} />}
renderItem={({ item }) => (
<MessageBubble
message={item.message}
parts={item.parts}
isDark={isDark}
onLongPress={handleMessageLongPress}
/>
)}
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" },
})