diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 23ba28b1c..6b2badff3 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -3,7 +3,9 @@ use std::collections::{HashMap, HashSet}; use crate::app_error::AppCommandError; use crate::db::entities::conversation; use crate::db::entities::folder::FolderKind; -use crate::db::service::{conversation_service, folder_service, import_service, tab_service}; +use crate::db::service::{ + conversation_edit_service, conversation_service, folder_service, import_service, tab_service, +}; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; use crate::models::*; @@ -1141,6 +1143,19 @@ pub async fn get_folder_conversation_core( .unwrap_or_default(); inject_delegation_meta(&mut turns, &children); + // User-message edits hide the replaced tail. Applied here so every + // consumer (detail, older-page, live window) sees the same transcript. + match conversation_edit_service::get_hidden_timestamps(conn, conversation_id).await { + Ok(hidden) if !hidden.is_empty() => { + turns = conversation_edit_service::filter_hidden_turns(turns, &hidden); + summary.message_count = turns.len() as u32; + } + Ok(_) => {} + Err(e) => tracing::warn!( + "[conversations] failed to load edit-hidden timestamps for {conversation_id}: {e}" + ), + } + Ok(( DbConversationDetail { summary, @@ -2000,6 +2015,42 @@ pub async fn update_conversation_pinned( Ok(()) } +/// Persist timestamps of turns hidden by editing a previous user message. +/// See `conversation_edit_service`. An empty list is rejected so a missed +/// turn id cannot wipe (or no-op-hide) the transcript by accident. +pub async fn hide_conversation_turns_core( + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, + hidden_timestamps_ms: Vec, +) -> Result<(), AppCommandError> { + if hidden_timestamps_ms.is_empty() { + return Err(AppCommandError::invalid_input( + "hidden_timestamps_ms must not be empty", + )); + } + conversation_service::get_by_id(conn, conversation_id) + .await + .map_err(AppCommandError::from)?; + conversation_edit_service::add_hidden_timestamps( + conn, + conversation_id, + &hidden_timestamps_ms, + ) + .await + .map_err(AppCommandError::from)?; + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn hide_conversation_turns( + db: tauri::State<'_, AppDatabase>, + conversation_id: i32, + hidden_timestamps_ms: Vec, +) -> Result<(), AppCommandError> { + hide_conversation_turns_core(&db.conn, conversation_id, hidden_timestamps_ms).await +} + pub async fn delete_conversation_core( conn: &sea_orm::DatabaseConnection, conversation_id: i32, diff --git a/src-tauri/src/db/entities/conversation_edit_hidden.rs b/src-tauri/src/db/entities/conversation_edit_hidden.rs new file mode 100644 index 000000000..594c6ea61 --- /dev/null +++ b/src-tauri/src/db/entities/conversation_edit_hidden.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +/// Per-conversation timestamps of transcript turns hidden by an edit. +/// See `crate::db::service::conversation_edit_service`. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "conversation_edit_hidden")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub conversation_id: i32, + /// JSON array of millisecond timestamps, e.g. `[1710000000000, …]`. + pub hidden_ts_json: String, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 1506c19c3..b68fc6fd2 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -7,6 +7,7 @@ pub mod chat_channel_message_log; pub mod chat_channel_sender_context; pub mod chat_channel_thread_binding; pub mod conversation; +pub mod conversation_edit_hidden; pub mod custom_agent; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index aed43e131..2582ddbf3 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -9,6 +9,7 @@ pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; pub use super::chat_channel_thread_binding::Entity as ChatChannelThreadBinding; pub use super::conversation::Entity as Conversation; +pub use super::conversation_edit_hidden::Entity as ConversationEditHidden; pub use super::custom_agent::Entity as CustomAgent; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; diff --git a/src-tauri/src/db/migration/m20260815_000001_conversation_edit_hidden.rs b/src-tauri/src/db/migration/m20260815_000001_conversation_edit_hidden.rs new file mode 100644 index 000000000..d7f5f9737 --- /dev/null +++ b/src-tauri/src/db/migration/m20260815_000001_conversation_edit_hidden.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ConversationEditHidden::Table) + .if_not_exists() + .col( + ColumnDef::new(ConversationEditHidden::ConversationId) + .integer() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ConversationEditHidden::HiddenTsJson) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ConversationEditHidden::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ConversationEditHidden::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ConversationEditHidden { + Table, + ConversationId, + HiddenTsJson, + UpdatedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 539f4a98d..f2771f4c8 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -37,6 +37,7 @@ mod m20260803_000001_folder_link; mod m20260803_000001_token_usage; mod m20260807_000001_work_task_scheduled_at; mod m20260808_000001_custom_agent_supports_mcp; +mod m20260815_000001_conversation_edit_hidden; pub struct Migrator; #[async_trait::async_trait] @@ -80,6 +81,7 @@ impl MigratorTrait for Migrator { Box::new(m20260803_000001_token_usage::Migration), Box::new(m20260807_000001_work_task_scheduled_at::Migration), Box::new(m20260808_000001_custom_agent_supports_mcp::Migration), + Box::new(m20260815_000001_conversation_edit_hidden::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_edit_service.rs b/src-tauri/src/db/service/conversation_edit_service.rs new file mode 100644 index 000000000..10f39e663 --- /dev/null +++ b/src-tauri/src/db/service/conversation_edit_service.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeSet; + +use chrono::Utc; +use sea_orm::sea_query::OnConflict; +use sea_orm::{DatabaseConnection, EntityTrait, Set}; + +use crate::db::entities::conversation_edit_hidden; +use crate::db::error::DbError; +use crate::models::message::MessageTurn; + +/// Timestamps currently hidden for this conversation. Empty when the user +/// has never edited a message in it. +pub async fn get_hidden_timestamps( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + let Some(row) = conversation_edit_hidden::Entity::find_by_id(conversation_id) + .one(conn) + .await? + else { + return Ok(BTreeSet::new()); + }; + Ok(parse_hidden_ts_json(&row.hidden_ts_json)) +} + +/// Union `added` into the stored hide set. An empty `added` is a no-op so a +/// caller that failed to resolve the edited turn cannot wipe the transcript. +pub async fn add_hidden_timestamps( + conn: &DatabaseConnection, + conversation_id: i32, + added: &[i64], +) -> Result, DbError> { + if added.is_empty() { + return get_hidden_timestamps(conn, conversation_id).await; + } + let mut hidden = get_hidden_timestamps(conn, conversation_id).await?; + hidden.extend(added.iter().copied()); + let now = Utc::now(); + let json = serde_json::to_string(&hidden.iter().copied().collect::>()) + .unwrap_or_else(|_| "[]".to_string()); + let model = conversation_edit_hidden::ActiveModel { + conversation_id: Set(conversation_id), + hidden_ts_json: Set(json), + updated_at: Set(now), + }; + conversation_edit_hidden::Entity::insert(model) + .on_conflict( + OnConflict::column(conversation_edit_hidden::Column::ConversationId) + .update_columns([ + conversation_edit_hidden::Column::HiddenTsJson, + conversation_edit_hidden::Column::UpdatedAt, + ]) + .to_owned(), + ) + .exec(conn) + .await?; + Ok(hidden) +} + +/// Drop turns whose timestamp is in `hidden`. Unparseable timestamps stay +/// visible — better a leftover than a silently swallowed turn. +pub fn filter_hidden_turns(turns: Vec, hidden: &BTreeSet) -> Vec { + if hidden.is_empty() { + return turns; + } + turns + .into_iter() + .filter(|turn| !hidden.contains(&turn.timestamp.timestamp_millis())) + .collect() +} + +fn parse_hidden_ts_json(raw: &str) -> BTreeSet { + serde_json::from_str::>(raw) + .unwrap_or_default() + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use crate::models::message::{MessageTurn, TurnRole}; + + fn turn(id: &str, ms: i64) -> MessageTurn { + MessageTurn { + id: id.to_string(), + role: TurnRole::User, + blocks: vec![], + timestamp: Utc.timestamp_millis_opt(ms).single().expect("valid ms"), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + } + } + + #[test] + fn filter_drops_only_hidden_timestamps() { + let turns = vec![turn("a", 1000), turn("b", 2000), turn("c", 3000)]; + let hidden = BTreeSet::from([2000]); + let kept: Vec<_> = filter_hidden_turns(turns, &hidden) + .into_iter() + .map(|t| t.id) + .collect(); + assert_eq!(kept, ["a", "c"]); + } + + #[test] + fn filter_is_noop_when_empty() { + let turns = vec![turn("a", 1000)]; + let hidden = BTreeSet::new(); + assert_eq!(filter_hidden_turns(turns.clone(), &hidden).len(), 1); + } + + #[test] + fn parse_accepts_a_json_array_and_ignores_garbage() { + assert_eq!( + parse_hidden_ts_json("[1, 2, 2, 3]"), + BTreeSet::from([1, 2, 3]) + ); + assert!(parse_hidden_ts_json("nope").is_empty()); + } +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index edda1d933..f2582efee 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -3,6 +3,7 @@ pub mod app_metadata_service; pub mod automation_service; pub mod chat_channel_message_log_service; pub mod chat_channel_service; +pub mod conversation_edit_service; pub mod conversation_service; pub mod custom_agent_service; pub mod folder_command_service; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f780ebbb0..1c2b2e448 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -988,6 +988,7 @@ mod tauri_app { conversations::update_conversation_status, conversations::update_conversation_title, conversations::update_conversation_pinned, + conversations::hide_conversation_turns, conversations::delete_conversation, folders::load_folder_history, folders::get_folder, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 0912e3b66..5fbf2559b 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -369,6 +369,26 @@ pub async fn update_conversation_pinned( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HideConversationTurnsParams { + pub conversation_id: i32, + pub hidden_timestamps_ms: Vec, +} + +pub async fn hide_conversation_turns( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + conv_commands::hide_conversation_turns_core( + &state.db.conn, + params.conversation_id, + params.hidden_timestamps_ms, + ) + .await?; + Ok(Json(())) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteConversationParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index faae6d2cb..c189ab1b2 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -159,6 +159,10 @@ pub fn build_router( "/update_conversation_pinned", post(handlers::conversations::update_conversation_pinned), ) + .route( + "/hide_conversation_turns", + post(handlers::conversations::hide_conversation_turns), + ) .route( "/delete_conversation", post(handlers::conversations::delete_conversation), diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 446f195eb..bcfd1271d 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -55,6 +55,10 @@ interface ChatInputProps { isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void + isEditingUserMessage?: boolean + editingUserTurnId?: string | null + editingUserBlocks?: PromptInputBlock[] | null + onCancelUserEdit?: () => void onForkSend?: (draft: PromptDraft, modeId?: string | null) => void /** Inject the draft's text into the RUNNING turn over the native steering * channel. Present only when the session's live-feedback channel is native @@ -116,6 +120,10 @@ export const ChatInput = memo(function ChatInput({ isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, + isEditingUserMessage, + editingUserTurnId, + editingUserBlocks, + onCancelUserEdit, onForkSend, onSteer, onAddFeedback, @@ -127,6 +135,7 @@ export const ChatInput = memo(function ChatInput({ tall = false, }: ChatInputProps) { const t = useTranslations("Folder.chat.chatInput") + const tList = useTranslations("Folder.chat.messageList") const isConnected = status === "connected" const isPrompting = status === "prompting" const isConnecting = status === "connecting" @@ -166,6 +175,11 @@ export const ChatInput = memo(function ChatInput({ editingItemId={editingItemId ?? null} /> )} + {isEditingUserMessage ? ( +
+ {tList("editingMessage")} +
+ ) : null} void onCancelQueueEdit?: () => void + isEditingUserMessage?: boolean + editingUserTurnId?: string | null + editingUserBlocks?: PromptInputBlock[] | null + onCancelUserEdit?: () => void onForkSend?: (draft: PromptDraft, modeId?: string | null) => void /** Inject the draft's text into the RUNNING turn (native live-feedback * steering). Present only for sessions on the native channel; threaded @@ -170,6 +174,10 @@ export function ConversationShell({ isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, + isEditingUserMessage, + editingUserTurnId, + editingUserBlocks, + onCancelUserEdit, onForkSend, onSteer, topBanner, @@ -312,6 +320,10 @@ export function ConversationShell({ isEditingQueueItem={isEditingQueueItem} onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} + isEditingUserMessage={isEditingUserMessage} + editingUserTurnId={editingUserTurnId} + editingUserBlocks={editingUserBlocks} + onCancelUserEdit={onCancelUserEdit} onForkSend={onForkSend} onSteer={onSteer} onAddFeedback={onAddFeedback} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 73da0539f..f99735ed1 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -182,6 +182,9 @@ interface MessageInputProps { isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void + /** Editing a previous user message: hydrate like a queue edit, but Send + * goes through `onSend` so the parent can truncate and resubmit. */ + isEditingUserMessage?: boolean /** Fork the session and send `draft`. Fire-and-forget: the input consumes the * draft synchronously (clears on click); the parent re-queues it if the fork * can't run, so it is never lost. */ @@ -293,6 +296,7 @@ export function MessageInput({ editingDraftText, editingDraftBlocks, isEditingQueueItem = false, + isEditingUserMessage = false, onSaveQueueEdit, onCancelQueueEdit, onForkSend, @@ -399,6 +403,8 @@ export function MessageInput({ isPromptingRef.current = isPrompting }, [isPrompting]) + const isComposerEdit = isEditingQueueItem || isEditingUserMessage + useEffect(() => { // navigator.clipboard is undefined at runtime in non-secure contexts even // though the DOM types claim it is always present, so guard with typeof. @@ -426,7 +432,7 @@ export function MessageInput({ const draftSaveTimerRef = useRef(null) const scheduleDraftSave = useCallback(() => { if (typeof window === "undefined") return - if (!effectiveDraftStorageKey || isEditingQueueItem) return + if (!effectiveDraftStorageKey || isComposerEdit) return if (draftSaveTimerRef.current != null) { window.clearTimeout(draftSaveTimerRef.current) } @@ -443,7 +449,7 @@ export function MessageInput({ ) } }, 300) - }, [effectiveDraftStorageKey, isEditingQueueItem]) + }, [effectiveDraftStorageKey, isComposerEdit]) useEffect(() => { return () => { @@ -467,7 +473,7 @@ export function MessageInput({ // with a synchronous flushSync() — running that here in the effect body // trips React's "flushSync from inside a lifecycle method" warning. if ( - isEditingQueueItem && + isComposerEdit && (editingDraftBlocks != null || editingDraftText != null) ) { prevEditingItemIdRef.current = editingItemId ?? null @@ -476,7 +482,7 @@ export function MessageInput({ const ed = editorRef.current if (!ed) return if ( - isEditingQueueItem && + isComposerEdit && (editingDraftBlocks != null || editingDraftText != null) ) { const editor = ed.getEditor() @@ -500,7 +506,7 @@ export function MessageInput({ return () => cancelAnimationFrame(raf) }, [ composerReady, - isEditingQueueItem, + isComposerEdit, editingItemId, editingDraftText, editingDraftBlocks, @@ -532,7 +538,7 @@ export function MessageInput({ // switching between two items with identical text still reloads. useEffect(() => { if ( - isEditingQueueItem && + isComposerEdit && editingItemId != null && editingItemId !== prevEditingItemIdRef.current ) { @@ -551,11 +557,11 @@ export function MessageInput({ editorRef.current?.focus() }) return () => cancelAnimationFrame(raf) - } else if (!isEditingQueueItem) { + } else if (!isComposerEdit) { prevEditingItemIdRef.current = null } }, [ - isEditingQueueItem, + isComposerEdit, editingItemId, editingDraftText, editingDraftBlocks, @@ -1099,7 +1105,7 @@ export function MessageInput({ // The editor stays editable while `disabled` (the agent is busy) so the user // can keep typing, but a plain send is blocked — only enqueue / queue-edit // save go through. Mirrors the legacy textarea's keydown guard. - if (disabled && !isPrompting && !isEditingQueueItem) return + if (disabled && !isPrompting && !isComposerEdit) return // An image whose web/remote upload hasn't settled has no server-side uri // yet — the transport would strip its base64 and the backend would have // nothing to hydrate. Block ALL three branches below (send / enqueue / @@ -1137,6 +1143,7 @@ export function MessageInput({ tAttach, buildDraft, isEditingQueueItem, + isComposerEdit, isPrompting, onSaveQueueEdit, onEnqueue, @@ -1291,7 +1298,7 @@ export function MessageInput({ (e: React.KeyboardEvent) => { if (isImeCompositionKey(e)) return if ( - isEditingQueueItem && + isComposerEdit && e.key === "Escape" && !slashMenuOpen && onCancelQueueEdit @@ -1300,7 +1307,7 @@ export function MessageInput({ onCancelQueueEdit() } }, - [isEditingQueueItem, slashMenuOpen, onCancelQueueEdit] + [isComposerEdit, slashMenuOpen, onCancelQueueEdit] ) // Clicking the input's empty chrome (its padding, the blank space below a @@ -1515,7 +1522,7 @@ export function MessageInput({ t, ]) - const actionButtons = isEditingQueueItem ? ( + const actionButtons = isComposerEdit ? (
) : isPrompting && onCancel ? ( diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index d32b40346..483c8c499 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -79,6 +79,7 @@ import { createChatDir, createConversation, getFolderConversation, + hideConversationTurns, openSettingsWindow, } from "@/lib/api" import { isWindowedDetail } from "@/lib/turn-window" @@ -90,10 +91,15 @@ import { shouldRejectDuplicateCreate, } from "@/lib/queue-flush" import { TurnBusyError } from "@/lib/turn-busy" +import { + contentBlocksToPromptInput, + timestampsToHideFrom, +} from "@/lib/edit-user-message" import { getConversationIdByExternalIdFromStore, getRuntimeSession, getTimelineTurns, + selectTimelineTurns, useConversationRuntimeActions, useConversationRuntimeStore, } from "@/stores/conversation-runtime-store" @@ -111,6 +117,7 @@ import { type MessageTurn, type PlanApprovalAnswer, type PromptDraft, + type PromptInputBlock, type QuestionAnswer, type UserMessageBlock, } from "@/lib/types" @@ -284,6 +291,7 @@ const ConversationTabView = memo(function ConversationTabView({ const { appendOptimisticTurn, removeOptimisticTurn, + truncateTurnsFrom, appendViewerUserTurn, completeTurn, refetchDetail, @@ -308,6 +316,10 @@ const ConversationTabView = memo(function ConversationTabView({ number | null >(null) const dbConversationId = conversationId ?? createdConversationId + const [editingUserTurn, setEditingUserTurn] = useState<{ + turnId: string + blocks: PromptInputBlock[] + } | null>(null) const [draftAgentType, setDraftAgentType] = useState(agentType) const selectedAgent = conversationId != null ? agentType : draftAgentType // Seed from localStorage so the React state reflects the user's saved @@ -952,11 +964,51 @@ const ConversationTabView = memo(function ConversationTabView({ // deliver to the wrong workspace. Same predicate the flush effect uses. if (!connectionReady) return + const replacing = editingUserTurn + if (replacing) { + // The replacement is this send. Drop the edited turn and everything + // after it from the transcript we show, then persist the hide set so + // a reload does not resurrect the tail. The agent still has those + // turns in its own store — ACP cannot rewind them — and this prompt + // is the new latest instruction, same as a native "I meant this". + const wasPrompting = connStatus === "prompting" + if (wasPrompting) { + handleCancel() + } + const timeline = selectTimelineTurns( + useConversationRuntimeStore.getState(), + effectiveConversationId + ) + const hidden = timestampsToHideFrom( + timeline.map((item) => item.turn), + replacing.turnId + ) + const persistId = dbConvIdRef.current + if (persistId != null && hidden.length > 0) { + void hideConversationTurns(persistId, hidden).catch((err) => { + console.error("[ConversationTabView] hide edited turns:", err) + }) + } + truncateTurnsFrom(effectiveConversationId, replacing.turnId, hidden) + setEditingUserTurn(null) + if (wasPrompting) { + // Do not race session/cancel. Queue the replacement so it flushes + // once the connection is idle, same as a mid-turn typed follow-up. + mqEnqueue(draft, selectedModeIdArg ?? null) + return + } + } + const fromQueueFlush = opts?.fromQueueFlush ?? false // Preserve FIFO: a direct send issued while the queue is non-empty joins // the tail rather than racing ahead of the queued items. Read the // queue length synchronously (it reflects a same-tick bounce requeue). - if (shouldQueueDirectSend(fromQueueFlush, mqGetQueueLength())) { + // An edit-and-send is a replacement of history, not a new follow-up, so + // it must not wait behind items queued against the discarded tail. + if ( + !replacing && + shouldQueueDirectSend(fromQueueFlush, mqGetQueueLength()) + ) { mqEnqueue(draft, selectedModeIdArg ?? null) return } @@ -1176,6 +1228,10 @@ const ConversationTabView = memo(function ConversationTabView({ [ appendOptimisticTurn, removeOptimisticTurn, + truncateTurnsFrom, + editingUserTurn, + handleCancel, + connStatus, mqEnqueue, mqRequeueFront, mqGetQueueLength, @@ -1509,6 +1565,7 @@ const ConversationTabView = memo(function ConversationTabView({ const handleQueueEdit = useCallback( (id: string) => { + setEditingUserTurn(null) mqStartEditing(id) }, [mqStartEditing] @@ -1527,6 +1584,22 @@ const ConversationTabView = memo(function ConversationTabView({ [mqEditingItemId, mqUpdateItem] ) + const handleEditUserMessage = useCallback( + (turn: MessageTurn) => { + if (turn.role !== "user" || conn.isViewer) return + mqCancelEditing() + setEditingUserTurn({ + turnId: turn.id, + blocks: contentBlocksToPromptInput(turn.blocks), + }) + }, + [conn.isViewer, mqCancelEditing] + ) + + const handleCancelUserEdit = useCallback(() => { + setEditingUserTurn(null) + }, []) + const showDraftHeader = !hasPersistedConversation && !hasSentMessage const isWelcomeMode = showDraftHeader @@ -1722,6 +1795,7 @@ const ConversationTabView = memo(function ConversationTabView({ onNewSession={ canShowDetailErrorActions ? handleOpenNewSession : undefined } + onEditUserMessage={!conn.isViewer ? handleEditUserMessage : undefined} /> ) @@ -1832,6 +1906,10 @@ const ConversationTabView = memo(function ConversationTabView({ isEditingQueueItem={mqEditingItemId != null} onSaveQueueEdit={handleSaveQueueEdit} onCancelQueueEdit={handleQueueCancelEdit} + isEditingUserMessage={editingUserTurn != null} + editingUserTurnId={editingUserTurn?.turnId ?? null} + editingUserBlocks={editingUserTurn?.blocks ?? null} + onCancelUserEdit={handleCancelUserEdit} onForkSend={ connStatus === "connected" && hasPersistedConversation && diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index cdaf46d5d..373983f3e 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -50,6 +50,7 @@ import { CopyIcon, Info, Loader2, + Pencil, Plus, RefreshCw, ListTodo, @@ -62,6 +63,7 @@ import { extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" import type { AgentType, ConnectionStatus, MessageTurn } from "@/lib/types" +import { canEditUserTurn } from "@/lib/edit-user-message" import { copyTextToClipboard } from "@/lib/utils" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" import { @@ -105,6 +107,11 @@ interface MessageListViewProps { * items render in arbitrary order and multiplicity. `null` = no divider. */ userTurnHeader?: ((group: ResolvedMessageGroup) => string | null) | null + /** + * Edit a persisted user turn (restore it into the composer). Absent in + * read-only embeds (sub-agent dialog, live task transcript). + */ + onEditUserMessage?: (turn: MessageTurn) => void } export interface ResolvedMessageGroup { @@ -536,6 +543,26 @@ const UserMessageCopyButton = memo(function UserMessageCopyButton({ ) }) +const UserMessageEditButton = memo(function UserMessageEditButton({ + turn, + onEdit, +}: { + turn: MessageTurn + onEdit: (turn: MessageTurn) => void +}) { + const t = useTranslations("Folder.chat.messageList") + return ( + onEdit(turn)} + size="icon-xs" + > + + + ) +}) + const UserMessageTaskButton = memo(function UserMessageTaskButton({ parts, }: { @@ -566,6 +593,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ previousUserIndex = null, isResponseComplete = true, sourceTurns, + onEdit, }: { group: ResolvedMessageGroup dimmed?: boolean @@ -573,6 +601,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ previousUserIndex?: number | null isResponseComplete?: boolean sourceTurns?: MessageTurn[] + onEdit?: (turn: MessageTurn) => void }) { if (group.role === "system") { return @@ -586,6 +615,9 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ ) : null} {group.role === "user" ? (
+ {onEdit && sourceTurns?.[0] ? ( + + ) : null} @@ -675,6 +707,7 @@ export function MessageListView({ onNewSession, showMessageNav = true, userTurnHeader = null, + onEditUserMessage, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") @@ -907,6 +940,15 @@ export function MessageListView({ previousUserIndex={item.previousUserIndex} isResponseComplete={item.phase === "persisted"} sourceTurns={item.sourceTurns} + onEdit={ + onEditUserMessage && + canEditUserTurn({ + role: item.group.role, + phase: item.phase, + }) + ? onEditUserMessage + : undefined + } />
) @@ -924,7 +966,7 @@ export function MessageListView({ return null } }, - [userTurnHeader] + [userTurnHeader, onEditUserMessage] ) const emptyState = useMemo( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..8682ca5e5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2928,6 +2928,8 @@ "emptyConversation": "لا توجد رسائل في هذه المحادثة.", "systemMessage": "رسالة النظام", "copyMessage": "نسخ", + "editMessage": "تعديل", + "editingMessage": "جارٍ تعديل هذه الرسالة. الإرسال يستبدلها ويكمل من هنا.", "copied": "تم النسخ", "downloadImage": "تنزيل الصورة", "downloadFailed": "فشل التنزيل: {message}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..05f852d5f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2928,6 +2928,8 @@ "emptyConversation": "Keine Nachrichten in dieser Unterhaltung.", "systemMessage": "Systemnachricht", "copyMessage": "Kopieren", + "editMessage": "Bearbeiten", + "editingMessage": "Diese Nachricht wird bearbeitet. Senden ersetzt sie und macht hier weiter.", "copied": "Kopiert", "downloadImage": "Bild herunterladen", "downloadFailed": "Download fehlgeschlagen: {message}", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..1c426d657 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2928,6 +2928,8 @@ "emptyConversation": "No messages in this conversation.", "systemMessage": "System message", "copyMessage": "Copy", + "editMessage": "Edit", + "editingMessage": "Editing this message. Send to replace it and continue from here.", "copied": "Copied", "downloadImage": "Download image", "downloadFailed": "Download failed: {message}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..208a55a16 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2928,6 +2928,8 @@ "emptyConversation": "No hay mensajes en esta conversación.", "systemMessage": "Mensaje del sistema", "copyMessage": "Copiar", + "editMessage": "Editar", + "editingMessage": "Editando este mensaje. Enviar lo reemplaza y continúa desde aquí.", "copied": "Copiado", "downloadImage": "Descargar imagen", "downloadFailed": "Descarga fallida: {message}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..918638c64 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2928,6 +2928,8 @@ "emptyConversation": "Aucun message dans cette conversation.", "systemMessage": "Message système", "copyMessage": "Copier", + "editMessage": "Modifier", + "editingMessage": "Modification de ce message. Envoyer le remplace et continue à partir d'ici.", "copied": "Copié", "downloadImage": "Télécharger l'image", "downloadFailed": "Échec du téléchargement : {message}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..eaacd26c6 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2928,6 +2928,8 @@ "emptyConversation": "この会話にはメッセージがありません。", "systemMessage": "システムメッセージ", "copyMessage": "コピー", + "editMessage": "編集", + "editingMessage": "このメッセージを編集中です。送信すると置き換わり、ここから続きを送ります。", "copied": "コピー済み", "downloadImage": "画像をダウンロード", "downloadFailed": "ダウンロードに失敗しました: {message}", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..bbd84237e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2928,6 +2928,8 @@ "emptyConversation": "이 대화에는 메시지가 없습니다.", "systemMessage": "시스템 메시지", "copyMessage": "복사", + "editMessage": "편집", + "editingMessage": "이 메시지를 편집 중입니다. 보내면 이 내용으로 바꾸고 여기서 이어갑니다.", "copied": "복사됨", "downloadImage": "이미지 다운로드", "downloadFailed": "다운로드 실패: {message}", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..9353d695b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2928,6 +2928,8 @@ "emptyConversation": "Nenhuma mensagem nesta conversa.", "systemMessage": "Mensagem do sistema", "copyMessage": "Copiar", + "editMessage": "Editar", + "editingMessage": "Editando esta mensagem. Enviar substitui e continua daqui.", "copied": "Copiado", "downloadImage": "Baixar imagem", "downloadFailed": "Falha no download: {message}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..c2c12d2e4 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2928,6 +2928,8 @@ "emptyConversation": "当前会话暂无消息。", "systemMessage": "系统消息", "copyMessage": "复制", + "editMessage": "编辑", + "editingMessage": "正在编辑这条消息。发送后会替换它并从这里继续。", "copied": "已复制", "downloadImage": "下载图片", "downloadFailed": "下载失败:{message}", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..1783b2cfc 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2928,6 +2928,8 @@ "emptyConversation": "目前會話暫無訊息。", "systemMessage": "系統訊息", "copyMessage": "複製", + "editMessage": "編輯", + "editingMessage": "正在編輯這則訊息。送出後會取代它並從這裡繼續。", "copied": "已複製", "downloadImage": "下載圖片", "downloadFailed": "下載失敗:{message}", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9b547f2cf..4bf9d5441 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2836,6 +2836,17 @@ export async function updateConversationPinned( }) } +/** Persist timestamps of transcript turns hidden by editing a user message. */ +export async function hideConversationTurns( + conversationId: number, + hiddenTimestampsMs: number[] +): Promise { + return getTransport().call("hide_conversation_turns", { + conversationId, + hiddenTimestampsMs, + }) +} + export async function deleteConversation( conversationId: number ): Promise { diff --git a/src/lib/edit-user-message.test.ts b/src/lib/edit-user-message.test.ts new file mode 100644 index 000000000..936d5b7ca --- /dev/null +++ b/src/lib/edit-user-message.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest" + +import { + canEditUserTurn, + contentBlocksToPromptInput, + filterHiddenTurns, + timestampsToHideFrom, + turnTimestampMs, +} from "./edit-user-message" +import type { MessageTurn } from "@/lib/types" + +function turn( + id: string, + timestamp: string, + role: MessageTurn["role"] = "user" +): MessageTurn { + return { id, role, blocks: [{ type: "text", text: id }], timestamp } +} + +describe("turnTimestampMs", () => { + it("parses an ISO timestamp", () => { + expect(turnTimestampMs({ timestamp: "2026-08-15T12:00:00.000Z" })).toBe( + Date.parse("2026-08-15T12:00:00.000Z") + ) + }) + + it("returns null for garbage", () => { + expect(turnTimestampMs({ timestamp: "not-a-date" })).toBeNull() + }) +}) + +describe("timestampsToHideFrom", () => { + const turns = [ + turn("u1", "2026-08-15T12:00:00.000Z"), + turn("a1", "2026-08-15T12:00:01.000Z", "assistant"), + turn("u2", "2026-08-15T12:00:02.000Z"), + turn("a2", "2026-08-15T12:00:03.000Z", "assistant"), + ] + + it("hides the edited user turn and everything after it", () => { + expect(timestampsToHideFrom(turns, "u2")).toEqual([ + Date.parse("2026-08-15T12:00:02.000Z"), + Date.parse("2026-08-15T12:00:03.000Z"), + ]) + }) + + it("hides the whole tail when the first user message is edited", () => { + expect(timestampsToHideFrom(turns, "u1")).toHaveLength(4) + }) + + it("returns empty when the turn is missing", () => { + expect(timestampsToHideFrom(turns, "nope")).toEqual([]) + }) +}) + +describe("filterHiddenTurns", () => { + const turns = [ + turn("u1", "2026-08-15T12:00:00.000Z"), + turn("a1", "2026-08-15T12:00:01.000Z", "assistant"), + turn("u2", "2026-08-15T12:00:02.000Z"), + ] + + it("drops only the hidden timestamps and keeps order", () => { + const hidden = [Date.parse("2026-08-15T12:00:01.000Z")] + expect(filterHiddenTurns(turns, hidden).map((t) => t.id)).toEqual([ + "u1", + "u2", + ]) + }) + + it("is a no-op on an empty hide set", () => { + expect(filterHiddenTurns(turns, [])).toBe(turns) + }) + + it("keeps a turn whose timestamp cannot be parsed", () => { + const messy = [turn("bad", "???")] + expect(filterHiddenTurns(messy, [1])).toEqual(messy) + }) +}) + +describe("contentBlocksToPromptInput", () => { + it("keeps text and image, drops everything else", () => { + expect( + contentBlocksToPromptInput([ + { type: "text", text: "fix the build" }, + { type: "thinking", text: "nope" }, + { + type: "image", + data: "abc", + mime_type: "image/png", + uri: "file:///a.png", + }, + { type: "text", text: "" }, + ]) + ).toEqual([ + { type: "text", text: "fix the build" }, + { + type: "image", + data: "abc", + mime_type: "image/png", + uri: "file:///a.png", + }, + ]) + }) +}) + +describe("canEditUserTurn", () => { + it("allows a persisted user turn", () => { + expect(canEditUserTurn({ role: "user", phase: "persisted" })).toBe(true) + }) + + it("rejects optimistic, streaming, assistant, and read-only turns", () => { + expect(canEditUserTurn({ role: "user", phase: "optimistic" })).toBe(false) + expect(canEditUserTurn({ role: "user", phase: "streaming" })).toBe(false) + expect(canEditUserTurn({ role: "assistant", phase: "persisted" })).toBe( + false + ) + expect( + canEditUserTurn({ role: "user", phase: "persisted", readOnly: true }) + ).toBe(false) + }) +}) diff --git a/src/lib/edit-user-message.ts b/src/lib/edit-user-message.ts new file mode 100644 index 000000000..c68e9de3b --- /dev/null +++ b/src/lib/edit-user-message.ts @@ -0,0 +1,99 @@ +import type { ContentBlock, MessageTurn, PromptInputBlock } from "@/lib/types" + +/** + * Client-side edit of a previous user message. + * + * ACP has no `message/edit` and `session/fork` cannot yet fork from a + * midpoint (the RFD reserves `messageId` for that). Native harnesses still + * let you rewrite a prompt and continue from there. We do the same on the + * surfaces we own: + * + * 1. Restore the chosen user turn into the composer. + * 2. Hide that turn and every later turn from the transcript we display + * (and persist the hidden timestamps so a reload stays truncated). + * 3. Send the replacement as a normal `session/prompt` on the SAME session + * so every agent — Claude, Codex, Grok, custom ACP — uses the path it + * already understands. + * + * The agent still has the discarded turns in its own store (we never rewrite + * a CLI session file). The replacement is the latest user instruction, which + * is how a follow-up "I meant this instead" already works in those CLIs. + */ + +/** Milliseconds since epoch for a turn's timestamp, or null if unparseable. */ +export function turnTimestampMs( + turn: Pick +): number | null { + const ms = Date.parse(turn.timestamp) + return Number.isFinite(ms) ? ms : null +} + +/** + * Timestamps of `fromTurnId` and every turn after it, in the given order. + * Empty when the id is missing — the caller must not persist an empty hide + * (that would be a no-op hide of "nothing", not "everything"). + */ +export function timestampsToHideFrom( + turns: Pick[], + fromTurnId: string +): number[] { + const start = turns.findIndex((turn) => turn.id === fromTurnId) + if (start < 0) return [] + const hidden: number[] = [] + for (let i = start; i < turns.length; i++) { + const ms = turnTimestampMs(turns[i]) + if (ms != null) hidden.push(ms) + } + return hidden +} + +/** Drop turns whose timestamp is in the hidden set. Order is preserved. */ +export function filterHiddenTurns>( + turns: T[], + hiddenMs: Iterable +): T[] { + const hidden = hiddenMs instanceof Set ? hiddenMs : new Set(hiddenMs) + if (hidden.size === 0) return turns + return turns.filter((turn) => { + const ms = turnTimestampMs(turn) + return ms == null || !hidden.has(ms) + }) +} + +/** + * Restore a stored user turn into the composer. Only text and image blocks + * are sendable; tool/thinking/plan blocks never appear on a user turn and + * are dropped if they do. + */ +export function contentBlocksToPromptInput( + blocks: ContentBlock[] +): PromptInputBlock[] { + const out: PromptInputBlock[] = [] + for (const block of blocks) { + if (block.type === "text") { + if (block.text.length > 0) { + out.push({ type: "text", text: block.text }) + } + } else if (block.type === "image") { + out.push({ + type: "image", + data: block.data, + mime_type: block.mime_type, + uri: block.uri ?? null, + }) + } + } + return out +} + +export function canEditUserTurn(options: { + role: string + phase: "persisted" | "optimistic" | "streaming" + readOnly?: boolean +}): boolean { + return ( + options.role === "user" && + options.phase === "persisted" && + !options.readOnly + ) +} diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index 9c0afea48..bf40ee363 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -366,6 +366,15 @@ type Action = conversationId: number id: string } + | { + // Edit-previous-message: drop the edited user turn and every later turn + // from every in-memory list so the composer send starts a clean tail. + // `hiddenTimestampsMs` is the same set persisted to the DB. + type: "TRUNCATE_TURNS_FROM" + conversationId: number + fromTurnId: string + hiddenTimestampsMs: number[] + } | { // Cross-client VIEWER synthesizes the sender's user turn from a // `user_message` event / snapshot. Idempotent + sender-guarded in the @@ -1965,6 +1974,33 @@ function reducer( })) } + case "TRUNCATE_TURNS_FROM": { + const current = state.byConversationId.get(action.conversationId) + if (!current) return state + const hidden = new Set(action.hiddenTimestampsMs) + const keep = (turn: MessageTurn) => { + if (turn.id === action.fromTurnId) return false + const ms = Date.parse(turn.timestamp) + return !Number.isFinite(ms) || !hidden.has(ms) + } + const nextDetailTurns = current.detail + ? current.detail.turns.filter(keep) + : null + return updateSessionInState(state, action.conversationId, (s) => ({ + ...s, + detail: + s.detail && nextDetailTurns + ? { ...s.detail, turns: nextDetailTurns } + : s.detail, + localTurns: s.localTurns.filter(keep), + optimisticTurns: s.optimisticTurns.filter(keep), + backgroundTurns: s.backgroundTurns.filter((entry) => keep(entry.turn)), + liveMessage: null, + syncState: "idle", + activeTurnToken: null, + })) + } + case "APPEND_VIEWER_USER_TURN": { const current = state.byConversationId.get(action.conversationId) ?? @@ -2346,6 +2382,11 @@ export interface RuntimeActions { turnToken: string ) => void removeOptimisticTurn: (conversationId: number, id: string) => void + truncateTurnsFrom: ( + conversationId: number, + fromTurnId: string, + hiddenTimestampsMs: number[] + ) => void appendViewerUserTurn: (conversationId: number, turn: MessageTurn) => void applyBackgroundActivity: ( conversationId: number, @@ -3472,6 +3513,13 @@ export const useConversationRuntimeStore = create()(( }), removeOptimisticTurn: (conversationId, id) => dispatch({ type: "REMOVE_OPTIMISTIC_TURN", conversationId, id }), + truncateTurnsFrom: (conversationId, fromTurnId, hiddenTimestampsMs) => + dispatch({ + type: "TRUNCATE_TURNS_FROM", + conversationId, + fromTurnId, + hiddenTimestampsMs, + }), appendViewerUserTurn: (conversationId, turn) => dispatch({ type: "APPEND_VIEWER_USER_TURN", conversationId, turn }), applyBackgroundActivity: (conversationId, turns, watermark) =>