From 330794611a3bce64d3433fd07e6de9f6a85dc33a Mon Sep 17 00:00:00 2001 From: zip700 Date: Thu, 2 Apr 2026 18:30:09 -0700 Subject: [PATCH 1/6] feat: implement message editing and thread rollback functionality with UI support --- src-tauri/src/bin/codex_monitor_daemon.rs | 9 + .../src/bin/codex_monitor_daemon/rpc/codex.rs | 15 ++ src-tauri/src/codex/mod.rs | 21 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/shared/codex_core.rs | 13 ++ src/features/app/components/MainApp.tsx | 12 ++ .../app/hooks/useMainAppLayoutSurfaces.ts | 17 +- .../messages/components/MessageRows.tsx | 138 +++++++++++++- src/features/messages/components/Messages.tsx | 31 +++ .../messages/hooks/useMessageEdit.test.tsx | 178 ++++++++++++++++++ src/features/messages/hooks/useMessageEdit.ts | 107 +++++++++++ .../hooks/threadReducer/threadItemsSlice.ts | 15 ++ .../threads/hooks/useThreadMessaging.ts | 34 ++++ src/features/threads/hooks/useThreads.ts | 2 + .../threads/hooks/useThreadsReducer.test.ts | 77 ++++++++ .../threads/hooks/useThreadsReducer.ts | 5 + src/services/tauri.ts | 10 +- src/styles/messages.css | 122 +++++++++++- 18 files changed, 803 insertions(+), 4 deletions(-) create mode 100644 src/features/messages/hooks/useMessageEdit.test.tsx create mode 100644 src/features/messages/hooks/useMessageEdit.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 59bfc00bc..b4bc42b4d 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -786,6 +786,15 @@ impl DaemonState { codex_core::archive_thread_core(&self.sessions, workspace_id, thread_id).await } + async fn rollback_thread( + &self, + workspace_id: String, + thread_id: String, + turn_id: String, + ) -> Result { + codex_core::rollback_thread_core(&self.sessions, workspace_id, thread_id, turn_id).await + } + async fn compact_thread( &self, workspace_id: String, diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs index cc278938e..9a8803d7e 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs @@ -130,6 +130,21 @@ pub(super) async fn try_handle( }; Some(state.archive_thread(workspace_id, thread_id).await) } + "rollback_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let turn_id = match parse_string(params, "turnId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.rollback_thread(workspace_id, thread_id, turn_id).await) + } "compact_thread" => { let workspace_id = match parse_string(params, "workspaceId") { Ok(value) => value, diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index e55d1e9ee..d9bc9a2de 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -294,6 +294,27 @@ pub(crate) async fn archive_thread( codex_core::archive_thread_core(&state.sessions, workspace_id, thread_id).await } +#[tauri::command] +pub(crate) async fn rollback_thread( + workspace_id: String, + thread_id: String, + turn_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "rollback_thread", + json!({ "workspaceId": workspace_id, "threadId": thread_id, "turnId": turn_id }), + ) + .await; + } + + codex_core::rollback_thread_core(&state.sessions, workspace_id, thread_id, turn_id).await +} + #[tauri::command] pub(crate) async fn compact_thread( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83e9dacae..49aabc462 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -226,6 +226,7 @@ pub fn run() { codex::list_threads, codex::list_mcp_server_status, codex::archive_thread, + codex::rollback_thread, codex::compact_thread, codex::set_thread_name, codex::collaboration_mode_list, diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index a0f2a4ea6..f8be07743 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -372,6 +372,19 @@ pub(crate) async fn archive_thread_core( .await } +pub(crate) async fn rollback_thread_core( + sessions: &Mutex>>, + workspace_id: String, + thread_id: String, + turn_id: String, +) -> Result { + let session = get_session_clone(sessions, &workspace_id).await?; + let params = json!({ "threadId": thread_id, "turnId": turn_id }); + session + .send_request_for_workspace(&workspace_id, "thread/rollback", params) + .await +} + pub(crate) async fn compact_thread_core( sessions: &Mutex>>, workspace_id: String, diff --git a/src/features/app/components/MainApp.tsx b/src/features/app/components/MainApp.tsx index 1ce7cc587..255da6202 100644 --- a/src/features/app/components/MainApp.tsx +++ b/src/features/app/components/MainApp.tsx @@ -66,6 +66,7 @@ import { useRemoteThreadLiveConnection } from "@app/hooks/useRemoteThreadLiveCon import { useTrayRecentThreads } from "@app/hooks/useTrayRecentThreads"; import { useTraySessionUsage } from "@app/hooks/useTraySessionUsage"; import { useTauriEvent } from "@app/hooks/useTauriEvent"; +import { useMessageEdit } from "@/features/messages/hooks/useMessageEdit"; import { useAppBootstrapOrchestration } from "@app/bootstrap/useAppBootstrapOrchestration"; import { useThreadCodexBootstrapOrchestration, @@ -494,6 +495,7 @@ export default function MainApp() { handleUserInputSubmit, refreshAccountInfo, refreshAccountRateLimits, + editAndRegenerateMessage, } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -516,6 +518,15 @@ export default function MainApp() { threadSortKey: threadListSortKey, onThreadCodexMetadataDetected: handleThreadCodexMetadataDetected, }); + + const messageEditState = useMessageEdit({ + onRegenerate: async (itemId, newText, images) => { + if (!activeWorkspace || !activeThreadId) { + return; + } + await editAndRegenerateMessage(activeWorkspace, activeThreadId, itemId, newText, images); + }, + }); const { connectionState: remoteThreadConnectionState, reconnectLive } = useRemoteThreadLiveConnection({ backendMode: appSettings.backendMode, @@ -1648,6 +1659,7 @@ export default function MainApp() { promptActions, worktreeState, sidebarHandlers: sidebarMenuOrchestration, + messageEditState, displayNodes, threadPinning: { pinThread, diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index b6ac05279..883d2120e 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; -import type { AppSettings, ComposerEditorSettings, WorkspaceInfo } from "@/types"; +import type { AppSettings, ComposerEditorSettings, ConversationItem, WorkspaceInfo } from "@/types"; +import type { UseMessageEditResult } from "@/features/messages/hooks/useMessageEdit"; import type { ThreadState } from "@/features/threads/hooks/useThreadsReducer"; import type { WorkspaceLaunchScriptsState } from "@app/hooks/useWorkspaceLaunchScripts"; import { REMOTE_THREAD_POLL_INTERVAL_MS } from "@app/hooks/useRemoteThreadRefreshOnFocus"; @@ -225,6 +226,7 @@ type UseMainAppLayoutSurfacesArgs = { dismissErrorToast: LayoutNodesOptions["primary"]["errorToastsProps"]["onDismiss"]; showDebugButton: boolean; handleDebugClick: () => void; + messageEditState?: UseMessageEditResult; }; type MainAppLayoutSurfacesContext = UseMainAppLayoutSurfacesArgs & { @@ -374,6 +376,7 @@ function buildPrimarySurface({ dismissErrorToast, showDebugButton, handleDebugClick, + messageEditState, }: MainAppLayoutSurfacesContext): LayoutNodesOptions["primary"] { return { sidebarProps: { @@ -465,6 +468,16 @@ function buildPrimarySurface({ : null, showPollingFetchStatus: showMobilePollingFetchStatus, pollingIntervalMs: REMOTE_THREAD_POLL_INTERVAL_MS, + editingItemId: messageEditState?.editingItemId, + editText: messageEditState?.editText, + isConfirmingEdit: messageEditState?.isConfirming, + isRegeneratingEdit: messageEditState?.isRegenerating, + onStartEdit: messageEditState?.startEdit ? (item: Extract) => messageEditState.startEdit(item.id, item.text, item.images) : undefined, + onCancelEdit: messageEditState?.cancelEdit, + onUpdateEditText: messageEditState?.updateEditText, + onRequestRegenerate: messageEditState?.requestRegenerate, + onCancelConfirm: messageEditState?.cancelConfirm, + onExecuteRegenerate: messageEditState?.executeRegenerate, }, composerProps: composerWorkspaceState.showComposer ? { @@ -1098,6 +1111,7 @@ export function useMainAppLayoutSurfaces({ dismissErrorToast, showDebugButton, handleDebugClick, + messageEditState, }: UseMainAppLayoutSurfacesArgs): LayoutNodesOptions { const sidebarRateLimits = activeWorkspace ? activeRateLimits : homeRateLimits; const sidebarAccount = activeWorkspace ? activeAccount : homeAccount; @@ -1260,6 +1274,7 @@ export function useMainAppLayoutSurfaces({ dismissErrorToast, showDebugButton, handleDebugClick, + messageEditState, sidebarRateLimits, sidebarAccount, }; diff --git a/src/features/messages/components/MessageRows.tsx b/src/features/messages/components/MessageRows.tsx index 9e2848606..3310e8663 100644 --- a/src/features/messages/components/MessageRows.tsx +++ b/src/features/messages/components/MessageRows.tsx @@ -8,9 +8,11 @@ import Diff from "lucide-react/dist/esm/icons/diff"; import FileDiffIcon from "lucide-react/dist/esm/icons/file-diff"; import FileText from "lucide-react/dist/esm/icons/file-text"; import Image from "lucide-react/dist/esm/icons/image"; +import Pencil from "lucide-react/dist/esm/icons/pencil"; import Quote from "lucide-react/dist/esm/icons/quote"; import Search from "lucide-react/dist/esm/icons/search"; import Terminal from "lucide-react/dist/esm/icons/terminal"; +import TriangleAlert from "lucide-react/dist/esm/icons/triangle-alert"; import Users from "lucide-react/dist/esm/icons/users"; import Wrench from "lucide-react/dist/esm/icons/wrench"; import X from "lucide-react/dist/esm/icons/x"; @@ -61,6 +63,16 @@ type MessageRowProps = MarkdownFileLinkProps & { onCopy: (item: Extract) => void; onQuote?: (item: Extract, selectedText?: string) => void; codeBlockCopyUseModifier?: boolean; + isEditing?: boolean; + editText?: string; + isConfirming?: boolean; + isRegenerating?: boolean; + onStartEdit?: (item: Extract) => void; + onCancelEdit?: () => void; + onUpdateEditText?: (text: string) => void; + onRequestRegenerate?: () => void; + onCancelConfirm?: () => void; + onExecuteRegenerate?: () => void; }; type ReasoningRowProps = MarkdownFileLinkProps & { @@ -377,11 +389,24 @@ export const MessageRow = memo(function MessageRow({ onOpenFileLink, onOpenFileLinkMenu, onOpenThreadLink, + isEditing = false, + editText = "", + isConfirming = false, + isRegenerating = false, + onStartEdit, + onCancelEdit, + onUpdateEditText, + onRequestRegenerate, + onCancelConfirm, + onExecuteRegenerate, }: MessageRowProps) { const [lightboxIndex, setLightboxIndex] = useState(null); const bubbleRef = useRef(null); + const editTextareaRef = useRef(null); const selectionSnapshotRef = useRef(null); const hasText = item.text.trim().length > 0; + const isUserMessage = item.role === "user"; + const canEdit = isUserMessage && Boolean(onStartEdit) && !isRegenerating; const imageItems = useMemo(() => { if (!item.images || item.images.length === 0) { return []; @@ -402,6 +427,28 @@ export const MessageRow = memo(function MessageRow({ imageItems.length === 0 && isStandaloneMarkdownTable(item.text); + useEffect(() => { + if (isEditing && editTextareaRef.current) { + const textarea = editTextareaRef.current; + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + } + }, [isEditing]); + + const handleEditKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + if (isConfirming) { + onCancelConfirm?.(); + } else { + onCancelEdit?.(); + } + } + }, + [isConfirming, onCancelConfirm, onCancelEdit], + ); + const getSelectedMessageText = useCallback(() => { const bubble = bubbleRef.current; const selection = window.getSelection(); @@ -422,7 +469,7 @@ export const MessageRow = memo(function MessageRow({ return false; } const element = node instanceof Element ? node : node.parentElement; - return Boolean(element?.closest(".message-quote-button, .message-copy-button")); + return Boolean(element?.closest(".message-quote-button, .message-copy-button, .message-edit-button")); }; if (isWithinMessageControls(selection.anchorNode) || isWithinMessageControls(selection.focusNode)) { @@ -440,6 +487,84 @@ export const MessageRow = memo(function MessageRow({ onQuote(item, selectedText); }, [getSelectedMessageText, item, onQuote]); + if (isEditing) { + return ( +
+
+ {imageItems.length > 0 && ( + + )} +