Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src-tauri/src/bin/codex_monitor_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
codex_core::rollback_thread_core(&self.sessions, workspace_id, thread_id, turn_id).await
}

async fn compact_thread(
&self,
workspace_id: String,
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/shared/codex_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,19 @@ pub(crate) async fn archive_thread_core(
.await
}

pub(crate) async fn rollback_thread_core(
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
workspace_id: String,
thread_id: String,
turn_id: String,
) -> Result<Value, String> {
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<HashMap<String, Arc<WorkspaceSession>>>,
workspace_id: String,
Expand Down
12 changes: 12 additions & 0 deletions src/features/app/components/MainApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -494,6 +495,7 @@ export default function MainApp() {
handleUserInputSubmit,
refreshAccountInfo,
refreshAccountRateLimits,
editAndRegenerateMessage,
} = useThreads({
activeWorkspace,
onWorkspaceConnected: markWorkspaceConnected,
Expand All @@ -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,
Expand Down Expand Up @@ -1648,6 +1659,7 @@ export default function MainApp() {
promptActions,
worktreeState,
sidebarHandlers: sidebarMenuOrchestration,
messageEditState,
displayNodes,
threadPinning: {
pinThread,
Expand Down
17 changes: 16 additions & 1 deletion src/features/app/hooks/useMainAppLayoutSurfaces.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -225,6 +226,7 @@ type UseMainAppLayoutSurfacesArgs = {
dismissErrorToast: LayoutNodesOptions["primary"]["errorToastsProps"]["onDismiss"];
showDebugButton: boolean;
handleDebugClick: () => void;
messageEditState?: UseMessageEditResult;
};

type MainAppLayoutSurfacesContext = UseMainAppLayoutSurfacesArgs & {
Expand Down Expand Up @@ -374,6 +376,7 @@ function buildPrimarySurface({
dismissErrorToast,
showDebugButton,
handleDebugClick,
messageEditState,
}: MainAppLayoutSurfacesContext): LayoutNodesOptions["primary"] {
return {
sidebarProps: {
Expand Down Expand Up @@ -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<ConversationItem, { kind: "message" }>) => 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
? {
Expand Down Expand Up @@ -1098,6 +1111,7 @@ export function useMainAppLayoutSurfaces({
dismissErrorToast,
showDebugButton,
handleDebugClick,
messageEditState,
}: UseMainAppLayoutSurfacesArgs): LayoutNodesOptions {
const sidebarRateLimits = activeWorkspace ? activeRateLimits : homeRateLimits;
const sidebarAccount = activeWorkspace ? activeAccount : homeAccount;
Expand Down Expand Up @@ -1260,6 +1274,7 @@ export function useMainAppLayoutSurfaces({
dismissErrorToast,
showDebugButton,
handleDebugClick,
messageEditState,
sidebarRateLimits,
sidebarAccount,
};
Expand Down
138 changes: 137 additions & 1 deletion src/features/messages/components/MessageRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +63,16 @@ type MessageRowProps = MarkdownFileLinkProps & {
onCopy: (item: Extract<ConversationItem, { kind: "message" }>) => void;
onQuote?: (item: Extract<ConversationItem, { kind: "message" }>, selectedText?: string) => void;
codeBlockCopyUseModifier?: boolean;
isEditing?: boolean;
editText?: string;
isConfirming?: boolean;
isRegenerating?: boolean;
onStartEdit?: (item: Extract<ConversationItem, { kind: "message" }>) => void;
onCancelEdit?: () => void;
onUpdateEditText?: (text: string) => void;
onRequestRegenerate?: () => void;
onCancelConfirm?: () => void;
onExecuteRegenerate?: () => void;
};

type ReasoningRowProps = MarkdownFileLinkProps & {
Expand Down Expand Up @@ -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<number | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const editTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const selectionSnapshotRef = useRef<string | null>(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 [];
Expand All @@ -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<HTMLTextAreaElement>) => {
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();
Expand All @@ -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)) {
Expand All @@ -440,6 +487,84 @@ export const MessageRow = memo(function MessageRow({
onQuote(item, selectedText);
}, [getSelectedMessageText, item, onQuote]);

if (isEditing) {
return (
<div className={`message ${item.role}`}>
<div className="bubble message-bubble message-bubble-editing">
{imageItems.length > 0 && (
<MessageImageGrid
images={imageItems}
onOpen={setLightboxIndex}
hasText={hasText}
/>
)}
<textarea
ref={editTextareaRef}
className="message-edit-textarea"
value={editText}
onChange={(event) => onUpdateEditText?.(event.target.value)}
onKeyDown={handleEditKeyDown}
rows={Math.min(12, Math.max(3, editText.split("\n").length + 1))}
disabled={isRegenerating}
aria-label="Edit message"
/>
{isConfirming && (
<div className="message-edit-confirm" role="alert">
<div className="message-edit-confirm-warning">
<TriangleAlert size={14} aria-hidden />
<span>All messages after this point will be permanently removed.</span>
</div>
<div className="message-edit-confirm-actions">
<button
type="button"
className="message-edit-confirm-cancel"
onClick={onCancelConfirm}
>
Cancel
</button>
<button
type="button"
className="message-edit-confirm-proceed"
onClick={onExecuteRegenerate}
disabled={isRegenerating}
>
{isRegenerating ? "Regenerating…" : "Confirm & Regenerate"}
</button>
</div>
</div>
)}
{!isConfirming && (
<div className="message-edit-actions">
<button
type="button"
className="message-edit-cancel"
onClick={onCancelEdit}
disabled={isRegenerating}
>
Cancel
</button>
<button
type="button"
className="message-edit-regenerate"
onClick={onRequestRegenerate}
disabled={isRegenerating || !editText.trim()}
>
Regenerate
</button>
</div>
)}
{lightboxIndex !== null && imageItems.length > 0 && (
<ImageLightbox
images={imageItems}
activeIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
</div>
);
}

return (
<div className={`message ${item.role}`}>
<div
Expand Down Expand Up @@ -473,6 +598,17 @@ export const MessageRow = memo(function MessageRow({
onClose={() => setLightboxIndex(null)}
/>
)}
{canEdit && (
<button
type="button"
className="ghost message-edit-button"
onClick={() => onStartEdit?.(item)}
aria-label="Edit message"
title="Edit message"
>
<Pencil size={14} aria-hidden />
</button>
)}
{onQuote && hasText && (
<button
type="button"
Expand Down
Loading