diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 803f942909..bbf80477fb 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -6,7 +6,9 @@ use tauri::Manager; 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::*; @@ -1313,6 +1315,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, @@ -2336,6 +2351,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 0000000000..594c6ea610 --- /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 6d8bbe07ad..a35a41f6ab 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -8,6 +8,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 b40272cba9..4c4f826c92 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -10,6 +10,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 0000000000..d7f5f9737e --- /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 f805e73c41..1885f29651 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; mod m20260817_000001_work_task_conversation_title; mod m20260818_000001_work_task_source; mod m20260819_000001_work_task_completion_kind; @@ -87,6 +88,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), Box::new(m20260817_000001_work_task_conversation_title::Migration), Box::new(m20260818_000001_work_task_source::Migration), Box::new(m20260819_000001_work_task_completion_kind::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 0000000000..cc0d87f5f1 --- /dev/null +++ b/src-tauri/src/db/service/conversation_edit_service.rs @@ -0,0 +1,125 @@ +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, + agent_message_id: 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 49cbe4e877..72efa12397 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -4,6 +4,7 @@ pub mod automation_service; pub mod canvas_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 0a3d27c22d..dfa8d0e02c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1184,6 +1184,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 a46a20fc1e..9d9ae75a1c 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -379,6 +379,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 99116a14b3..3a37872283 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 a8ce461e35..0d4241f13c 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -58,6 +58,10 @@ interface ChatInputProps { isEditingQueueItem?: boolean onSaveQueueEdit?: (draft: PromptDraft) => void onCancelQueueEdit?: () => void + isEditingUserMessage?: boolean + editingUserTurnId?: string | null + editingUserBlocks?: PromptInputBlock[] | null + onCancelUserEdit?: () => void /** Send the draft into the RUNNING turn over the session's live-feedback * channel. Present only when the session has a working delivery channel * (`useSessionFeedback().steerAvailable`); resolves once recorded, rejects @@ -127,6 +131,10 @@ export const ChatInput = memo(function ChatInput({ isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, + isEditingUserMessage, + editingUserTurnId, + editingUserBlocks, + onCancelUserEdit, onSteer, steerChannel, onAddFeedback, @@ -138,6 +146,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" @@ -189,6 +198,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 /** Send the draft into the RUNNING turn over the session's live-feedback * channel. Present only for sessions with a working delivery channel; * threaded straight through to the composer. `blocks` carries the full @@ -202,6 +206,10 @@ export function ConversationShell({ isEditingQueueItem, onSaveQueueEdit, onCancelQueueEdit, + isEditingUserMessage, + editingUserTurnId, + editingUserBlocks, + onCancelUserEdit, onSteer, steerChannel, topBanner, @@ -373,6 +381,10 @@ export function ConversationShell({ isEditingQueueItem={isEditingQueueItem} onSaveQueueEdit={onSaveQueueEdit} onCancelQueueEdit={onCancelQueueEdit} + isEditingUserMessage={isEditingUserMessage} + editingUserTurnId={editingUserTurnId} + editingUserBlocks={editingUserBlocks} + onCancelUserEdit={onCancelUserEdit} onSteer={onSteer} steerChannel={steerChannel} onAddFeedback={onAddFeedback} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 0e228f1e92..70ff61ffe9 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -210,6 +210,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 /** Send the draft into the RUNNING turn over the session's live-feedback * channel (see {@link steerChannel}). Present only on sessions with a * working delivery channel — when absent, the prompting branch renders its @@ -330,6 +333,7 @@ export function MessageInput({ editingDraftText, editingDraftBlocks, isEditingQueueItem = false, + isEditingUserMessage = false, onSaveQueueEdit, onCancelQueueEdit, onSteer, @@ -436,6 +440,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. @@ -463,7 +469,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) } @@ -480,7 +486,7 @@ export function MessageInput({ ) } }, 300) - }, [effectiveDraftStorageKey, isEditingQueueItem]) + }, [effectiveDraftStorageKey, isComposerEdit]) useEffect(() => { return () => { @@ -504,7 +510,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 @@ -513,7 +519,7 @@ export function MessageInput({ const ed = editorRef.current if (!ed) return if ( - isEditingQueueItem && + isComposerEdit && (editingDraftBlocks != null || editingDraftText != null) ) { const editor = ed.getEditor() @@ -537,7 +543,7 @@ export function MessageInput({ return () => cancelAnimationFrame(raf) }, [ composerReady, - isEditingQueueItem, + isComposerEdit, editingItemId, editingDraftText, editingDraftBlocks, @@ -569,7 +575,7 @@ export function MessageInput({ // switching between two items with identical text still reloads. useEffect(() => { if ( - isEditingQueueItem && + isComposerEdit && editingItemId != null && editingItemId !== prevEditingItemIdRef.current ) { @@ -588,11 +594,11 @@ export function MessageInput({ editorRef.current?.focus() }) return () => cancelAnimationFrame(raf) - } else if (!isEditingQueueItem) { + } else if (!isComposerEdit) { prevEditingItemIdRef.current = null } }, [ - isEditingQueueItem, + isComposerEdit, editingItemId, editingDraftText, editingDraftBlocks, @@ -1218,7 +1224,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 / @@ -1256,6 +1262,7 @@ export function MessageInput({ tAttach, buildDraft, isEditingQueueItem, + isComposerEdit, isPrompting, onSaveQueueEdit, onEnqueue, @@ -1402,7 +1409,7 @@ export function MessageInput({ (e: React.KeyboardEvent) => { if (isImeCompositionKey(e)) return if ( - isEditingQueueItem && + isComposerEdit && e.key === "Escape" && !slashMenuVisible && onCancelQueueEdit @@ -1411,7 +1418,7 @@ export function MessageInput({ onCancelQueueEdit() } }, - [isEditingQueueItem, slashMenuVisible, onCancelQueueEdit] + [isComposerEdit, slashMenuVisible, onCancelQueueEdit] ) // Clicking the input's empty chrome (its padding, the blank space below a @@ -1626,7 +1633,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 0bd6fd992e..15d1f77401 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -80,6 +80,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" @@ -286,6 +292,7 @@ const ConversationTabView = memo(function ConversationTabView({ const { appendOptimisticTurn, removeOptimisticTurn, + truncateTurnsFrom, appendViewerUserTurn, completeTurn, refetchDetail, @@ -310,6 +317,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 @@ -999,11 +1010,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 } @@ -1223,6 +1274,10 @@ const ConversationTabView = memo(function ConversationTabView({ [ appendOptimisticTurn, removeOptimisticTurn, + truncateTurnsFrom, + editingUserTurn, + handleCancel, + connStatus, mqEnqueue, mqRequeueFront, mqGetQueueLength, @@ -1589,6 +1644,7 @@ const ConversationTabView = memo(function ConversationTabView({ const handleQueueEdit = useCallback( (id: string) => { + setEditingUserTurn(null) mqStartEditing(id) }, [mqStartEditing] @@ -1607,6 +1663,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 @@ -1989,6 +2061,7 @@ const ConversationTabView = memo(function ConversationTabView({ ? handleForkFromTurn : undefined } + onEditUserMessage={!conn.isViewer ? handleEditUserMessage : undefined} /> ) @@ -2124,6 +2197,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} onSteer={ // Any working delivery channel, not just the native push: the pull // tool records a waiting note the agent reads on its next check, and diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 4c5835d507..a88fbed778 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -54,6 +54,7 @@ import { CheckIcon, CopyIcon, Loader2, + Pencil, Plus, RefreshCw, ListTodo, @@ -66,6 +67,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 { SelectionActionBubble } from "@/components/message/selection-action-bubble" @@ -141,6 +143,11 @@ interface MessageListViewProps { * (see `forkBusy`) rather than making every reply's footer flicker. */ onForkFromTurn?: (turnId: string) => void + /** + * 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 { @@ -741,6 +748,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, }: { @@ -824,6 +851,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ onForkFromTurn, forkDisabled = false, isThreadTail = false, + onEdit, }: { group: ResolvedMessageGroup dimmed?: boolean @@ -840,6 +868,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ /** Whether nothing follows this group in the thread — the one position where * a turn the backend cannot name still forks where the user pointed. */ isThreadTail?: boolean + onEdit?: (turn: MessageTurn) => void }) { if (group.role === "system") { return @@ -860,6 +889,9 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ ) : null} {group.role === "user" ? (
+ {onEdit && sourceTurns?.[0] ? ( + + ) : null} @@ -979,6 +1011,7 @@ export function MessageListView({ onAskSelection, onSaveNoteSelection, onForkFromTurn, + onEditUserMessage, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") @@ -1274,6 +1307,15 @@ export function MessageListView({ onForkFromTurn={onForkFromTurn} forkDisabled={forkBusy} isThreadTail={item.isThreadTail} + onEdit={ + onEditUserMessage && + canEditUserTurn({ + role: item.group.role, + phase: item.phase, + }) + ? onEditUserMessage + : undefined + } />
) @@ -1299,6 +1341,7 @@ export function MessageListView({ handleRoundOpenChange, onForkFromTurn, forkBusy, + onEditUserMessage, ] ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 0b0d6eab51..77ab5dbee8 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3173,6 +3173,8 @@ "emptyConversation": "لا توجد رسائل في هذه المحادثة.", "systemMessage": "رسالة النظام", "copyMessage": "نسخ", + "editMessage": "تعديل", + "editingMessage": "جارٍ تعديل هذه الرسالة. الإرسال يستبدلها ويكمل من هنا.", "forkFromHere": "تفريع من هنا", "forkBusy": "لا يمكن التفريع أثناء تنفيذ دور", "forkNotReady": "لا يمكن التفريع من هذا الرد بعد، أعد المحاولة بعد قليل", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 917042e561..79f3ddbae1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3173,6 +3173,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.", "forkFromHere": "Ab hier verzweigen", "forkBusy": "Verzweigen nicht möglich, während ein Zug läuft", "forkNotReady": "Von dieser Antwort kann noch nicht verzweigt werden – bitte gleich erneut versuchen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ac1757fb18..e5fd3a52de 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3173,6 +3173,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.", "forkFromHere": "Fork from here", "forkBusy": "Can't fork while a turn is running", "forkNotReady": "Can't fork from this reply just yet — try again in a moment", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7d747ecb79..6d030acf6c 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3173,6 +3173,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í.", "forkFromHere": "Bifurcar desde aquí", "forkBusy": "No se puede bifurcar mientras hay un turno en curso", "forkNotReady": "Todavía no se puede bifurcar desde esta respuesta; inténtalo en un momento", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 480e1635b1..51c14e02e5 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3173,6 +3173,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.", "forkFromHere": "Bifurquer d'ici", "forkBusy": "Impossible de bifurquer pendant qu'un tour est en cours", "forkNotReady": "Impossible de bifurquer depuis cette réponse pour le moment, réessayez dans un instant", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6ab41949f2..c2c851e692 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3173,6 +3173,8 @@ "emptyConversation": "この会話にはメッセージがありません。", "systemMessage": "システムメッセージ", "copyMessage": "コピー", + "editMessage": "編集", + "editingMessage": "このメッセージを編集中です。送信すると置き換わり、ここから続きを送ります。", "forkFromHere": "ここから分岐", "forkBusy": "ターンの実行中は分岐できません", "forkNotReady": "この返信からはまだ分岐できません。少し待ってからお試しください", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 35875ca9cb..3644e107bb 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3173,6 +3173,8 @@ "emptyConversation": "이 대화에는 메시지가 없습니다.", "systemMessage": "시스템 메시지", "copyMessage": "복사", + "editMessage": "편집", + "editingMessage": "이 메시지를 편집 중입니다. 보내면 이 내용으로 바꾸고 여기서 이어갑니다.", "forkFromHere": "여기서 분기", "forkBusy": "턴이 실행 중일 때는 분기할 수 없습니다", "forkNotReady": "아직 이 응답에서 분기할 수 없습니다. 잠시 후 다시 시도하세요", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index d93e60ee0e..bc42ea7104 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3173,6 +3173,8 @@ "emptyConversation": "Nenhuma mensagem nesta conversa.", "systemMessage": "Mensagem do sistema", "copyMessage": "Copiar", + "editMessage": "Editar", + "editingMessage": "Editando esta mensagem. Enviar substitui e continua daqui.", "forkFromHere": "Bifurcar a partir daqui", "forkBusy": "Não é possível bifurcar enquanto há um turno em andamento", "forkNotReady": "Ainda não é possível bifurcar a partir desta resposta; tente novamente em instantes", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2e4ed58228..d623be1484 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3173,6 +3173,8 @@ "emptyConversation": "当前会话暂无消息。", "systemMessage": "系统消息", "copyMessage": "复制", + "editMessage": "编辑", + "editingMessage": "正在编辑这条消息。发送后会替换它并从这里继续。", "forkFromHere": "从此处分叉", "forkBusy": "当前有回合正在进行,暂不可分叉", "forkNotReady": "这条回复暂时还不能作为分叉点,请稍后再试", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 840fbfd9fa..65a438c393 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3173,6 +3173,8 @@ "emptyConversation": "目前會話暫無訊息。", "systemMessage": "系統訊息", "copyMessage": "複製", + "editMessage": "編輯", + "editingMessage": "正在編輯這則訊息。送出後會取代它並從這裡繼續。", "forkFromHere": "從此處分叉", "forkBusy": "目前有回合正在進行,暫不可分叉", "forkNotReady": "這則回覆暫時還不能作為分叉點,請稍後再試", diff --git a/src/lib/api.ts b/src/lib/api.ts index bf075e5b69..805bf834ed 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3236,6 +3236,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 0000000000..936d5b7cac --- /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 0000000000..c68e9de3b2 --- /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 e2c8f21cba..643537a653 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -401,6 +401,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 @@ -2288,6 +2297,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) ?? @@ -2672,6 +2708,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, @@ -3896,6 +3937,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) =>