* 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:
@@ -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" },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user