* 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,
|
sendMessage,
|
||||||
abortSession,
|
abortSession,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
|
revertToMessage,
|
||||||
|
unrevertSession,
|
||||||
} = useSessions()
|
} = useSessions()
|
||||||
|
|
||||||
// Derive sending state for this specific session
|
// Derive sending state for this specific session
|
||||||
@@ -156,18 +158,92 @@ export default function SessionScreen() {
|
|||||||
return [...custom, ...BUILTIN_COMMANDS]
|
return [...custom, ...BUILTIN_COMMANDS]
|
||||||
}, [serverCommands])
|
}, [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
|
// Inverted FlatList: data is reversed (newest first) so newest renders at bottom
|
||||||
const messageData = useMemo(
|
const messageData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(messages || [])
|
(messages || [])
|
||||||
|
.filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID)
|
||||||
.map((msg) => ({
|
.map((msg) => ({
|
||||||
message: msg,
|
message: msg,
|
||||||
parts: (parts && parts[msg.id]) || [],
|
parts: (parts && parts[msg.id]) || [],
|
||||||
}))
|
}))
|
||||||
.reverse(),
|
.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) => {
|
const scrollToBottom = useCallback((animated = true) => {
|
||||||
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
||||||
}, [])
|
}, [])
|
||||||
@@ -528,6 +604,17 @@ export default function SessionScreen() {
|
|||||||
</View>
|
</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 ? (
|
{isLoading ? (
|
||||||
<View style={s.loading}>
|
<View style={s.loading}>
|
||||||
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
@@ -539,7 +626,14 @@ export default function SessionScreen() {
|
|||||||
data={messageData}
|
data={messageData}
|
||||||
inverted
|
inverted
|
||||||
keyExtractor={(item) => item.message.id}
|
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}
|
contentContainerStyle={s.messageList}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={100}
|
scrollEventThrottle={100}
|
||||||
@@ -910,4 +1004,12 @@ const s = StyleSheet.create({
|
|||||||
bannerReconnecting: { backgroundColor: "#92400e" },
|
bannerReconnecting: { backgroundColor: "#92400e" },
|
||||||
bannerConnected: { backgroundColor: "#065f46" },
|
bannerConnected: { backgroundColor: "#065f46" },
|
||||||
bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" },
|
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" },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ interface Props {
|
|||||||
message: Message
|
message: Message
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
isDark: boolean
|
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
|
// 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
|
// built-in block-level memoization that eliminates re-renders for stable blocks
|
||||||
export const MessageBubble = memo(
|
export const MessageBubble = memo(
|
||||||
function MessageBubble({ message, parts, isDark }: Props) {
|
function MessageBubble({ message, parts, isDark, onLongPress }: Props) {
|
||||||
const isUser = message.role === "user"
|
const isUser = message.role === "user"
|
||||||
|
|
||||||
const textParts = parts.filter((p) => p.type === "text")
|
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") || ""
|
const reasoning = reasoningParts.map((p) => p.text).join("\n") || ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<TouchableOpacity
|
||||||
|
activeOpacity={isUser && onLongPress ? 0.7 : 1}
|
||||||
|
onLongPress={isUser && onLongPress ? () => onLongPress(message.id) : undefined}
|
||||||
|
disabled={!isUser || !onLongPress}
|
||||||
style={[
|
style={[
|
||||||
s.bubble,
|
s.bubble,
|
||||||
isUser ? s.user : s.assistant,
|
isUser ? s.user : s.assistant,
|
||||||
@@ -101,7 +108,7 @@ export const MessageBubble = memo(
|
|||||||
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
|
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
(prev, next) => {
|
(prev, next) => {
|
||||||
@@ -109,6 +116,7 @@ export const MessageBubble = memo(
|
|||||||
// This prevents completed messages from re-rendering during streaming
|
// This prevents completed messages from re-rendering during streaming
|
||||||
if (prev.message.id !== next.message.id) return false
|
if (prev.message.id !== next.message.id) return false
|
||||||
if (prev.isDark !== next.isDark) 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
|
if (prev.parts.length !== next.parts.length) return false
|
||||||
// Compare the last part's text content - this is what changes during streaming
|
// Compare the last part's text content - this is what changes during streaming
|
||||||
const prevLast = prev.parts[prev.parts.length - 1]
|
const prevLast = prev.parts[prev.parts.length - 1]
|
||||||
|
|||||||
32
src/lib/prompt-from-parts.test.ts
Normal file
32
src/lib/prompt-from-parts.test.ts
Normal file
@@ -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: [] })
|
||||||
|
})
|
||||||
20
src/lib/prompt-from-parts.ts
Normal file
20
src/lib/prompt-from-parts.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -35,6 +35,13 @@ export interface Session {
|
|||||||
deletions: number
|
deletions: number
|
||||||
files: 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 {
|
export interface Message {
|
||||||
@@ -163,6 +170,18 @@ export interface HealthResponse {
|
|||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 30_000
|
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 {
|
function createHeaders(config: ClientConfig): HeadersInit {
|
||||||
return buildRequestHeaders(config)
|
return buildRequestHeaders(config)
|
||||||
}
|
}
|
||||||
@@ -177,7 +196,7 @@ async function request<T>(config: ClientConfig, path: string, options: RequestIn
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text()
|
const error = await response.text()
|
||||||
throw new Error(`API Error: ${response.status} - ${error}`)
|
throw new ApiError(response.status, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -365,6 +384,20 @@ export function createClient(config: ClientConfig) {
|
|||||||
const qs = messageID ? `?messageID=${messageID}` : ""
|
const qs = messageID ? `?messageID=${messageID}` : ""
|
||||||
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
|
return request<unknown[]>(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<Session>(config, `/session/${sessionID}/revert`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(partID ? { messageID, partID } : { messageID }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
unrevert: (sessionID: string) =>
|
||||||
|
request<Session>(config, `/session/${sessionID}/unrevert`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
permission: {
|
permission: {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { create } from "zustand"
|
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 { useConnections } from "./connections"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
import { AnalyticsEvent, track } from "../lib/analytics"
|
import { AnalyticsEvent, track } from "../lib/analytics"
|
||||||
|
import { extractPromptFromParts, type PromptFromParts } from "../lib/prompt-from-parts"
|
||||||
|
|
||||||
// Helper to convert API response to our internal format
|
// Helper to convert API response to our internal format
|
||||||
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
||||||
@@ -50,10 +51,16 @@ interface SessionsState {
|
|||||||
abortSession: () => Promise<void>
|
abortSession: () => Promise<void>
|
||||||
refreshMessages: () => Promise<void>
|
refreshMessages: () => Promise<void>
|
||||||
|
|
||||||
|
// Revert (edit sent message) / unrevert (undo the pending revert)
|
||||||
|
revertToMessage: (messageID: string) => Promise<RevertResult>
|
||||||
|
unrevertSession: () => Promise<void>
|
||||||
|
|
||||||
// Event handling
|
// Event handling
|
||||||
handleEvent: (event: Event) => void
|
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
|
// Sessions the user aborted since they last went busy. Mirrors events.ts's
|
||||||
// erroredSessions: SessionStatus has no "aborted" variant — an aborted run
|
// erroredSessions: SessionStatus has no "aborted" variant — an aborted run
|
||||||
// still ends with a busy -> idle transition — so without this mark a
|
// still ends with a busy -> idle transition — so without this mark a
|
||||||
@@ -341,6 +348,54 @@ export const useSessions = create<SessionsState>((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) => {
|
handleEvent: (event) => {
|
||||||
const { currentSession } = get()
|
const { currentSession } = get()
|
||||||
if (!currentSession) return
|
if (!currentSession) return
|
||||||
|
|||||||
Reference in New Issue
Block a user