From bd18e087549e58611e3c5cccb34bb0027671ee2d Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:59:01 -0700 Subject: [PATCH 1/3] feat(chat): two-way composer draft sync across desktop and mobile --- src-tauri/src/commands/conversations.rs | 76 ++++++- .../entities/conversation_composer_draft.rs | 19 ++ src-tauri/src/db/entities/mod.rs | 1 + ...0815_000002_conversation_composer_draft.rs | 64 ++++++ src-tauri/src/db/migration/mod.rs | 2 + .../conversation_composer_draft_service.rs | 206 ++++++++++++++++++ src-tauri/src/db/service/mod.rs | 1 + src-tauri/src/lib.rs | 2 + src-tauri/src/web/event_bridge.rs | 45 ++++ src-tauri/src/web/handlers/conversations.rs | 43 ++++ src-tauri/src/web/router.rs | 8 + src/components/chat/message-input.tsx | 38 +++- src/hooks/use-composer-draft-sync.ts | 155 +++++++++++++ src/lib/api.ts | 20 ++ src/lib/composer-draft-sync.test.ts | 67 ++++++ src/lib/composer-draft-sync.ts | 47 ++++ src/lib/types.ts | 26 +++ 17 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/db/entities/conversation_composer_draft.rs create mode 100644 src-tauri/src/db/migration/m20260815_000002_conversation_composer_draft.rs create mode 100644 src-tauri/src/db/service/conversation_composer_draft_service.rs create mode 100644 src/hooks/use-composer-draft-sync.ts create mode 100644 src/lib/composer-draft-sync.test.ts create mode 100644 src/lib/composer-draft-sync.ts diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 23ba28b1c..7db410a87 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -3,7 +3,10 @@ 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_composer_draft_service, conversation_service, folder_service, import_service, + tab_service, +}; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; use crate::models::*; @@ -26,9 +29,10 @@ use crate::parsers::{ ParseError, }; use crate::web::event_bridge::{ - emit_event, ConversationChange, ConversationsBulkChanged, EventEmitter, ImportScanProgress, - TabsChanged, CONVERSATIONS_BULK_CHANGED_EVENT, CONVERSATION_CHANGED_EVENT, - IMPORT_SCAN_PROGRESS_EVENT, TABS_CHANGED_EVENT, + emit_event, ComposerDraftChanged, ConversationChange, ConversationsBulkChanged, EventEmitter, + ImportScanProgress, TabsChanged, COMPOSER_DRAFT_CHANGED_EVENT, + CONVERSATIONS_BULK_CHANGED_EVENT, CONVERSATION_CHANGED_EVENT, IMPORT_SCAN_PROGRESS_EVENT, + TABS_CHANGED_EVENT, }; pub async fn list_all_conversations_core( @@ -2000,6 +2004,70 @@ pub async fn update_conversation_pinned( Ok(()) } +/// Fetch the unsent composer text for a persisted conversation. `None` when +/// no client has saved a draft yet. The body is only returned here — the +/// WS notify never carries it. +pub async fn get_composer_draft_core( + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, +) -> Result, AppCommandError> { + conversation_composer_draft_service::get(conn, conversation_id) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_composer_draft( + db: tauri::State<'_, AppDatabase>, + conversation_id: i32, +) -> Result, AppCommandError> { + get_composer_draft_core(&db.conn, conversation_id).await +} + +/// Last-write-wins save of unsent composer text. Broadcasts +/// [`COMPOSER_DRAFT_CHANGED_EVENT`] with ids only (no body). +pub async fn put_composer_draft_core( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + conversation_id: i32, + text: String, + origin: String, +) -> Result { + let result = + conversation_composer_draft_service::put(conn, conversation_id, text, origin).await?; + emit_event( + emitter, + COMPOSER_DRAFT_CHANGED_EVENT, + ComposerDraftChanged { + conversation_id: result.conversation_id, + revision: result.revision, + origin: result.origin.clone(), + cleared: result.cleared, + }, + ); + Ok(result) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn put_composer_draft( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + conversation_id: i32, + text: String, + origin: String, +) -> Result { + put_composer_draft_core( + &db.conn, + &EventEmitter::Tauri(app), + conversation_id, + text, + origin, + ) + .await +} + pub async fn delete_conversation_core( conn: &sea_orm::DatabaseConnection, conversation_id: i32, diff --git a/src-tauri/src/db/entities/conversation_composer_draft.rs b/src-tauri/src/db/entities/conversation_composer_draft.rs new file mode 100644 index 000000000..07fb4de68 --- /dev/null +++ b/src-tauri/src/db/entities/conversation_composer_draft.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +/// Per-conversation unsent composer text, shared across desktop / web / mobile. +/// See `crate::db::service::conversation_composer_draft_service`. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "conversation_composer_draft")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub conversation_id: i32, + pub text: String, + pub revision: i64, + pub origin: 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..7944fa8f6 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_composer_draft; pub mod custom_agent; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/migration/m20260815_000002_conversation_composer_draft.rs b/src-tauri/src/db/migration/m20260815_000002_conversation_composer_draft.rs new file mode 100644 index 000000000..c1991504c --- /dev/null +++ b/src-tauri/src/db/migration/m20260815_000002_conversation_composer_draft.rs @@ -0,0 +1,64 @@ +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(ConversationComposerDraft::Table) + .if_not_exists() + .col( + ColumnDef::new(ConversationComposerDraft::ConversationId) + .integer() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ConversationComposerDraft::Text) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ConversationComposerDraft::Revision) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(ConversationComposerDraft::Origin) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ConversationComposerDraft::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ConversationComposerDraft::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ConversationComposerDraft { + Table, + ConversationId, + Text, + Revision, + Origin, + UpdatedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 539f4a98d..5acd357fc 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_000002_conversation_composer_draft; 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_000002_conversation_composer_draft::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_composer_draft_service.rs b/src-tauri/src/db/service/conversation_composer_draft_service.rs new file mode 100644 index 000000000..3d4ce1c5a --- /dev/null +++ b/src-tauri/src/db/service/conversation_composer_draft_service.rs @@ -0,0 +1,206 @@ +use chrono::Utc; +use sea_orm::sea_query::OnConflict; +use sea_orm::{DatabaseConnection, EntityTrait, Set}; +use serde::Serialize; + +use crate::app_error::AppCommandError; +use crate::db::entities::conversation_composer_draft; +use crate::db::error::DbError; +use crate::db::service::conversation_service; + +/// Hard cap on persisted composer text. Matches a generous chat box, not a +/// file upload. Enforced on UTF-8 byte length so a crafted payload cannot +/// grow the SQLite row without bound. +pub const MAX_DRAFT_BYTES: usize = 256 * 1024; +/// Client origin id (uuid-ish). Stored + echoed on the WS notify; never a +/// secret, just long enough to ignore our own broadcast. +pub const MAX_ORIGIN_LEN: usize = 64; + +/// Full draft as returned by GET. `text` is the composer's visible string +/// (not a Tiptap document) so mobile and desktop share one representation. +#[derive(Debug, Clone, Serialize)] +pub struct ComposerDraft { + pub conversation_id: i32, + pub text: String, + pub revision: i64, + pub origin: String, +} + +/// PUT result. Deliberately omits `text` so a log of the response cannot +/// leak the composer body. +#[derive(Debug, Clone, Serialize)] +pub struct ComposerDraftPutResult { + pub conversation_id: i32, + pub revision: i64, + pub origin: String, + pub cleared: bool, +} + +pub fn validate_origin(origin: &str) -> Result<(), AppCommandError> { + if origin.is_empty() || origin.len() > MAX_ORIGIN_LEN { + return Err(AppCommandError::invalid_input( + "origin must be 1-64 characters", + )); + } + if !origin + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(AppCommandError::invalid_input( + "origin must be alphanumeric, hyphen, or underscore", + )); + } + Ok(()) +} + +pub fn validate_text(text: &str) -> Result<(), AppCommandError> { + if text.len() > MAX_DRAFT_BYTES { + return Err(AppCommandError::invalid_input( + "composer draft exceeds 256 KiB", + )); + } + Ok(()) +} + +pub async fn get( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + conversation_service::get_by_id(conn, conversation_id).await?; + let Some(row) = conversation_composer_draft::Entity::find_by_id(conversation_id) + .one(conn) + .await? + else { + return Ok(None); + }; + Ok(Some(ComposerDraft { + conversation_id: row.conversation_id, + text: row.text, + revision: row.revision, + origin: row.origin, + })) +} + +/// Last-write-wins upsert. Every accepted PUT increments `revision` so +/// clients can ignore their own echo and apply only newer remote revisions. +/// An empty `text` still writes a row (a tombstone) so a clear on one +/// device can wipe the other. +pub async fn put( + conn: &DatabaseConnection, + conversation_id: i32, + text: String, + origin: String, +) -> Result { + validate_origin(&origin)?; + validate_text(&text)?; + conversation_service::get_by_id(conn, conversation_id) + .await + .map_err(AppCommandError::from)?; + + let current = conversation_composer_draft::Entity::find_by_id(conversation_id) + .one(conn) + .await + .map_err(AppCommandError::from)?; + let next_revision = current.map(|row| row.revision + 1).unwrap_or(1); + let cleared = text.is_empty(); + let now = Utc::now(); + let model = conversation_composer_draft::ActiveModel { + conversation_id: Set(conversation_id), + text: Set(text), + revision: Set(next_revision), + origin: Set(origin.clone()), + updated_at: Set(now), + }; + conversation_composer_draft::Entity::insert(model) + .on_conflict( + OnConflict::column(conversation_composer_draft::Column::ConversationId) + .update_columns([ + conversation_composer_draft::Column::Text, + conversation_composer_draft::Column::Revision, + conversation_composer_draft::Column::Origin, + conversation_composer_draft::Column::UpdatedAt, + ]) + .to_owned(), + ) + .exec(conn) + .await + .map_err(AppCommandError::from)?; + + Ok(ComposerDraftPutResult { + conversation_id, + revision: next_revision, + origin, + cleared, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + use crate::models::agent::AgentType; + + #[test] + fn origin_rejects_empty_long_and_junk() { + assert!(validate_origin("").is_err()); + assert!(validate_origin(&"a".repeat(65)).is_err()); + assert!(validate_origin("bad origin").is_err()); + assert!(validate_origin("win-abc_1").is_ok()); + } + + #[test] + fn text_rejects_oversize() { + assert!(validate_text("ok").is_ok()); + assert!(validate_text(&"x".repeat(MAX_DRAFT_BYTES + 1)).is_err()); + } + + #[tokio::test] + async fn put_get_round_trip_and_clear() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/draft-sync").await; + let id = seed_conversation(&db, folder, AgentType::Grok).await; + + assert!(get(&db.conn, id).await.unwrap().is_none()); + + let first = put(&db.conn, id, "hello from desktop".into(), "desk1".into()) + .await + .unwrap(); + assert_eq!(first.revision, 1); + assert!(!first.cleared); + + let loaded = get(&db.conn, id).await.unwrap().unwrap(); + assert_eq!(loaded.text, "hello from desktop"); + assert_eq!(loaded.origin, "desk1"); + assert_eq!(loaded.revision, 1); + + let second = put(&db.conn, id, "hello from phone".into(), "phone1".into()) + .await + .unwrap(); + assert_eq!(second.revision, 2); + let loaded = get(&db.conn, id).await.unwrap().unwrap(); + assert_eq!(loaded.text, "hello from phone"); + assert_eq!(loaded.origin, "phone1"); + + let cleared = put(&db.conn, id, String::new(), "phone1".into()) + .await + .unwrap(); + assert!(cleared.cleared); + assert_eq!(cleared.revision, 3); + let loaded = get(&db.conn, id).await.unwrap().unwrap(); + assert_eq!(loaded.text, ""); + } + + #[tokio::test] + async fn put_requires_a_live_conversation() { + let db = fresh_in_memory_db().await; + let err = put(&db.conn, 999_999, "x".into(), "desk1".into()) + .await + .unwrap_err(); + let detail = err.detail.unwrap_or_default(); + assert!( + detail.contains("not found") || err.message.to_lowercase().contains("database"), + "missing conversation must fail closed, got {} / {detail}", + err.message + ); + } +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index edda1d933..9bcc5f603 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_composer_draft_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..ae84d95e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -988,6 +988,8 @@ mod tauri_app { conversations::update_conversation_status, conversations::update_conversation_title, conversations::update_conversation_pinned, + conversations::get_composer_draft, + conversations::put_composer_draft, conversations::delete_conversation, folders::load_folder_history, folders::get_folder, diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index 52dc5ef74..225ba55ec 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -287,6 +287,23 @@ pub struct ConversationsBulkChanged { /// webview and every WebSocket client. pub const TABS_CHANGED_EVENT: &str = "tabs://changed"; +/// Global side-channel for cross-client unsent composer text. The WS +/// payload is ids only (`conversation_id`, `revision`, `origin`, +/// `cleared`) — never the draft body — so a log of the firehose cannot +/// leak what the user is typing. Clients fetch the body over the +/// authenticated GET. +pub const COMPOSER_DRAFT_CHANGED_EVENT: &str = "composer-draft://changed"; + +/// Payload for [`COMPOSER_DRAFT_CHANGED_EVENT`]. Last-write-wins via +/// `revision`; `origin` is echoed so the writer ignores its own notify. +#[derive(Debug, Clone, Serialize)] +pub struct ComposerDraftChanged { + pub conversation_id: i32, + pub revision: i64, + pub origin: String, + pub cleared: bool, +} + /// Payload for the [`TABS_CHANGED_EVENT`] side-channel. Carries the full /// conversation-bound tab set (a snapshot, not a delta) so every client /// converges idempotently — matching the full-replacement save semantics. @@ -610,6 +627,34 @@ mod tests { assert!(p["tabs"].is_array(), "tabs must serialize as an array"); } + #[test] + fn emit_event_broadcasts_composer_draft_changed_without_body() { + // The notify is ids-only so a WS/log dump cannot leak unsent text. + let broadcaster = Arc::new(WebEventBroadcaster::new()); + let mut rx = broadcaster.subscribe(); + let emitter = EventEmitter::test_web_only(broadcaster.clone()); + + emit_event( + &emitter, + COMPOSER_DRAFT_CHANGED_EVENT, + ComposerDraftChanged { + conversation_id: 42, + revision: 3, + origin: "phone1".to_string(), + cleared: false, + }, + ); + + let evt = rx.try_recv().expect("draft change should broadcast"); + let p = &*evt.payload; + assert_eq!(evt.channel, COMPOSER_DRAFT_CHANGED_EVENT); + assert_eq!(p["conversation_id"], 42); + assert_eq!(p["revision"], 3); + assert_eq!(p["origin"], "phone1"); + assert_eq!(p["cleared"], false); + assert!(p.get("text").is_none(), "notify must never carry the draft body"); + } + #[test] fn emit_event_broadcasts_folder_change_upsert() { // A headlessly-created folder (e.g. an automation worktree) reaches every diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 0912e3b66..6e9658675 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -369,6 +369,49 @@ pub async fn update_conversation_pinned( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetComposerDraftParams { + pub conversation_id: i32, +} + +pub async fn get_composer_draft( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> +{ + Ok(Json( + conv_commands::get_composer_draft_core(&state.db.conn, params.conversation_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PutComposerDraftParams { + pub conversation_id: i32, + pub text: String, + pub origin: String, +} + +pub async fn put_composer_draft( + Extension(state): Extension>, + Json(params): Json, +) -> Result< + Json, + AppCommandError, +> { + Ok(Json( + conv_commands::put_composer_draft_core( + &state.db.conn, + &state.emitter, + params.conversation_id, + params.text, + params.origin, + ) + .await?, + )) +} + #[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..8b64fe0db 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -159,6 +159,14 @@ pub fn build_router( "/update_conversation_pinned", post(handlers::conversations::update_conversation_pinned), ) + .route( + "/get_composer_draft", + post(handlers::conversations::get_composer_draft), + ) + .route( + "/put_composer_draft", + post(handlers::conversations::put_composer_draft), + ) .route( "/delete_conversation", post(handlers::conversations::delete_conversation), diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 73da0539f..451b761d5 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -97,6 +97,8 @@ import { loadMessageInputDraftV2, saveMessageInputDraftV2, } from "@/lib/message-input-draft" +import { conversationIdFromDraftKey } from "@/lib/composer-draft-sync" +import { useComposerDraftSync } from "@/hooks/use-composer-draft-sync" import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { RichComposer, @@ -330,6 +332,30 @@ export function MessageInput({ // Flips true once the RichComposer's async (immediatelyRender:false) editor has // mounted, so the hydration effect can use the imperative handle. const [composerReady, setComposerReady] = useState(false) + const syncedConversationId = conversationIdFromDraftKey( + effectiveDraftStorageKey + ) + const applyingRemoteDraftRef = useRef(false) + const persistSyncedDraft = useComposerDraftSync({ + conversationId: syncedConversationId, + enabled: !isEditingQueueItem && composerReady, + getLocalText: () => editorRef.current?.getText() ?? "", + onRemote: (text) => { + const ed = editorRef.current + if (!ed) return + applyingRemoteDraftRef.current = true + ed.setText(text) + const editor = ed.getEditor() + setComposerEmpty(editor ? isComposerEmpty(editor) : text.length === 0) + if (typeof window !== "undefined") { + window.setTimeout(() => { + applyingRemoteDraftRef.current = false + }, 400) + } else { + applyingRemoteDraftRef.current = false + } + }, + }) const syncComposerEmpty = useCallback(() => { const ed = editorRef.current?.getEditor() @@ -436,14 +462,16 @@ export function MessageInput({ if (!ed || !effectiveDraftStorageKey) return if (ed.isEmpty()) { clearMessageInputDraftV2(effectiveDraftStorageKey) + if (!applyingRemoteDraftRef.current) persistSyncedDraft("") } else { saveMessageInputDraftV2( effectiveDraftStorageKey, stripEmbeddedReferences(ed.getJSON()) ) + if (!applyingRemoteDraftRef.current) persistSyncedDraft(ed.getText()) } }, 300) - }, [effectiveDraftStorageKey, isEditingQueueItem]) + }, [effectiveDraftStorageKey, isEditingQueueItem, persistSyncedDraft]) useEffect(() => { return () => { @@ -1122,6 +1150,10 @@ export function MessageInput({ // Prompting mode: enqueue instead of sending if (isPrompting && onEnqueue) { onEnqueue(draft, showModeSelector ? effectiveModeId : null) + if (effectiveDraftStorageKey) { + clearMessageInputDraftV2(effectiveDraftStorageKey) + } + persistSyncedDraft("") resetComposer() return } @@ -1130,6 +1162,7 @@ export function MessageInput({ if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) } + persistSyncedDraft("") resetComposer() }, [ disabled, @@ -1144,6 +1177,7 @@ export function MessageInput({ effectiveModeId, showModeSelector, effectiveDraftStorageKey, + persistSyncedDraft, resetComposer, ]) @@ -1165,6 +1199,7 @@ export function MessageInput({ onForkSend(draft, showModeSelector ? effectiveModeId : null) if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) + persistSyncedDraft("") } resetComposer() }, [ @@ -1175,6 +1210,7 @@ export function MessageInput({ effectiveModeId, showModeSelector, effectiveDraftStorageKey, + persistSyncedDraft, resetComposer, ]) diff --git a/src/hooks/use-composer-draft-sync.ts b/src/hooks/use-composer-draft-sync.ts new file mode 100644 index 000000000..bc1978d7b --- /dev/null +++ b/src/hooks/use-composer-draft-sync.ts @@ -0,0 +1,155 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" + +import { getComposerDraft, putComposerDraft } from "@/lib/api" +import { + getComposerClientOrigin, + shouldApplyRemoteDraft, +} from "@/lib/composer-draft-sync" +import { onTransportReconnect, subscribe } from "@/lib/platform" +import { + COMPOSER_DRAFT_CHANGED_EVENT, + type ComposerDraftChanged, +} from "@/lib/types" + +/** Two-way unsent-composer sync for a persisted conversation. + * + * GET on open / reconnect. PUT after the local draft save (caller). WS + * notify is ids-only; the body is fetched over the authenticated GET. + * Own-origin echoes are ignored so typing does not bounce. */ +export function useComposerDraftSync(opts: { + conversationId: number | null + enabled: boolean + getLocalText: () => string + onRemote: (text: string) => void +}): (text: string) => void { + const { conversationId, enabled, getLocalText, onRemote } = opts + const originRef = useRef(getComposerClientOrigin()) + const lastRevisionRef = useRef(0) + const lastPutTextRef = useRef(null) + const onRemoteRef = useRef(onRemote) + const getLocalTextRef = useRef(getLocalText) + onRemoteRef.current = onRemote + getLocalTextRef.current = getLocalText + + const applyIfNewer = useCallback( + async (hint?: ComposerDraftChanged) => { + if (!enabled || conversationId == null) return + if ( + hint && + !shouldApplyRemoteDraft({ + remoteRevision: hint.revision, + lastAppliedRevision: lastRevisionRef.current, + remoteOrigin: hint.origin, + localOrigin: originRef.current, + }) + ) { + if (hint.revision > lastRevisionRef.current) { + lastRevisionRef.current = hint.revision + } + return + } + try { + const remote = await getComposerDraft(conversationId) + if (!remote) { + // No server row yet: publish this device's local cache so a + // phone that opens the same chat can GET it. Skip when the + // box is empty so we do not create tombstones for idle tabs. + if (!hint) { + const local = getLocalTextRef.current() + if (local) { + lastPutTextRef.current = null + void putComposerDraft( + conversationId, + local, + originRef.current + ).then((result) => { + lastPutTextRef.current = local + lastRevisionRef.current = Math.max( + lastRevisionRef.current, + result.revision + ) + }) + } + } + return + } + if ( + !shouldApplyRemoteDraft({ + remoteRevision: remote.revision, + lastAppliedRevision: lastRevisionRef.current, + remoteOrigin: remote.origin, + localOrigin: originRef.current, + }) + ) { + lastRevisionRef.current = Math.max( + lastRevisionRef.current, + remote.revision + ) + return + } + lastRevisionRef.current = remote.revision + lastPutTextRef.current = remote.text + onRemoteRef.current(remote.text) + } catch { + // Draft sync is best-effort. A missed GET leaves the local box as-is. + } + }, + [conversationId, enabled] + ) + + useEffect(() => { + lastRevisionRef.current = 0 + lastPutTextRef.current = null + if (!enabled || conversationId == null) return + void applyIfNewer() + }, [applyIfNewer, conversationId, enabled]) + + useEffect(() => { + if (!enabled || conversationId == null) return + let disposed = false + let unlisten: (() => void) | undefined + void (async () => { + const dispose = await subscribe( + COMPOSER_DRAFT_CHANGED_EVENT, + (change) => { + if (change.conversation_id !== conversationId) return + void applyIfNewer(change) + } + ) + if (disposed) { + dispose() + return + } + unlisten = dispose + })() + const offReconnect = onTransportReconnect(() => { + void applyIfNewer() + }) + return () => { + disposed = true + unlisten?.() + offReconnect?.() + } + }, [applyIfNewer, conversationId, enabled]) + + return useCallback( + (text: string) => { + if (!enabled || conversationId == null) return + if (lastPutTextRef.current === text) return + lastPutTextRef.current = text + void putComposerDraft(conversationId, text, originRef.current) + .then((result) => { + lastRevisionRef.current = Math.max( + lastRevisionRef.current, + result.revision + ) + }) + .catch(() => { + // Leave lastPutText so we do not tight-loop a failing PUT. + }) + }, + [conversationId, enabled] + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9b547f2cf..4d91ca542 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -28,6 +28,8 @@ import type { WorkTaskTemplate, ConversationSummary, ConversationDetail, + ComposerDraft, + ComposerDraftPutResult, ConversationTurnsPage, DbConversationDetail, FolderInfo, @@ -2836,6 +2838,24 @@ export async function updateConversationPinned( }) } +export async function getComposerDraft( + conversationId: number +): Promise { + return getTransport().call("get_composer_draft", { conversationId }) +} + +export async function putComposerDraft( + conversationId: number, + text: string, + origin: string +): Promise { + return getTransport().call("put_composer_draft", { + conversationId, + text, + origin, + }) +} + export async function deleteConversation( conversationId: number ): Promise { diff --git a/src/lib/composer-draft-sync.test.ts b/src/lib/composer-draft-sync.test.ts new file mode 100644 index 000000000..956a7251c --- /dev/null +++ b/src/lib/composer-draft-sync.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" + +import { + conversationIdFromDraftKey, + shouldApplyRemoteDraft, +} from "./composer-draft-sync" + +describe("conversationIdFromDraftKey", () => { + it("accepts persisted conversation keys only", () => { + expect(conversationIdFromDraftKey("conv:12")).toBe(12) + expect(conversationIdFromDraftKey("conv:1")).toBe(1) + }) + + it("rejects new-tab drafts and junk", () => { + expect(conversationIdFromDraftKey("draft:abc")).toBeNull() + expect(conversationIdFromDraftKey("new")).toBeNull() + expect(conversationIdFromDraftKey("conv:")).toBeNull() + expect(conversationIdFromDraftKey("conv:-3")).toBeNull() + expect(conversationIdFromDraftKey("conv:0")).toBeNull() + expect(conversationIdFromDraftKey(null)).toBeNull() + }) +}) + +describe("shouldApplyRemoteDraft", () => { + const localOrigin = "desk-1" + + it("ignores the writer's own echo", () => { + expect( + shouldApplyRemoteDraft({ + remoteRevision: 9, + lastAppliedRevision: 1, + remoteOrigin: localOrigin, + localOrigin, + }) + ).toBe(false) + }) + + it("applies a newer revision from another client", () => { + expect( + shouldApplyRemoteDraft({ + remoteRevision: 4, + lastAppliedRevision: 3, + remoteOrigin: "phone-1", + localOrigin, + }) + ).toBe(true) + }) + + it("drops an equal or older revision", () => { + expect( + shouldApplyRemoteDraft({ + remoteRevision: 3, + lastAppliedRevision: 3, + remoteOrigin: "phone-1", + localOrigin, + }) + ).toBe(false) + expect( + shouldApplyRemoteDraft({ + remoteRevision: 2, + lastAppliedRevision: 3, + remoteOrigin: "phone-1", + localOrigin, + }) + ).toBe(false) + }) +}) diff --git a/src/lib/composer-draft-sync.ts b/src/lib/composer-draft-sync.ts new file mode 100644 index 000000000..54064eb00 --- /dev/null +++ b/src/lib/composer-draft-sync.ts @@ -0,0 +1,47 @@ +"use client" + +const CLIENT_ORIGIN_KEY = "codeg:client-origin" +const CONV_DRAFT_KEY = /^conv:(\d+)$/ + +/** Persisted conversations use `conv:` as the localStorage draft key. + * New-tab drafts (`draft:`) have no conversation row yet and must + * stay local-only — there is nothing authenticated to attach them to. */ +export function conversationIdFromDraftKey( + key: string | null | undefined +): number | null { + if (!key) return null + const match = CONV_DRAFT_KEY.exec(key) + if (!match) return null + const id = Number(match[1]) + return Number.isInteger(id) && id > 0 ? id : null +} + +/** Stable per-browser origin id. Echoed on PUT so this client can ignore + * its own `composer-draft://changed` notify. Not a secret. */ +export function getComposerClientOrigin(): string { + if (typeof window === "undefined") return "ssr" + try { + const existing = window.localStorage.getItem(CLIENT_ORIGIN_KEY) + if (existing && existing.length > 0 && existing.length <= 64) { + return existing + } + const fresh = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `web-${Date.now().toString(36)}` + window.localStorage.setItem(CLIENT_ORIGIN_KEY, fresh) + return fresh + } catch { + return "anon" + } +} + +export function shouldApplyRemoteDraft(opts: { + remoteRevision: number + lastAppliedRevision: number + remoteOrigin: string + localOrigin: string +}): boolean { + if (opts.remoteOrigin === opts.localOrigin) return false + return opts.remoteRevision > opts.lastAppliedRevision +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8404ddc14..60d35826e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -473,6 +473,32 @@ export interface TabsChanged { export const TABS_CHANGED_EVENT = "tabs://changed" +/** Payload for the global `composer-draft://changed` side-channel. Ids + * only — the unsent text is fetched over the authenticated GET. `origin` + * is echoed so the writer ignores its own notify. */ +export interface ComposerDraftChanged { + conversation_id: number + revision: number + origin: string + cleared: boolean +} + +export const COMPOSER_DRAFT_CHANGED_EVENT = "composer-draft://changed" + +export interface ComposerDraft { + conversation_id: number + text: string + revision: number + origin: string +} + +export interface ComposerDraftPutResult { + conversation_id: number + revision: number + origin: string + cleared: boolean +} + /** Response of `list_opened_tabs`: the persisted set + current workspace tab * version (clients seed their compare-and-set / echo logic from it). */ export interface OpenedTabsSnapshot { From 2c1dd9ffd2a1b70815021ae8b30bca487cbbc5bd Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:42:04 -0700 Subject: [PATCH 2/3] feat(chat): sync staged composer images and files with the draft --- src-tauri/src/commands/conversations.rs | 40 +- .../entities/conversation_composer_draft.rs | 3 + ...60815_000003_composer_draft_attachments.rs | 40 ++ src-tauri/src/db/migration/mod.rs | 2 + .../conversation_composer_draft_service.rs | 388 +++++++++++++++++- src-tauri/src/lib.rs | 2 + src-tauri/src/web/event_bridge.rs | 4 + src-tauri/src/web/handlers/conversations.rs | 6 + src-tauri/src/web/handlers/files.rs | 167 ++++++++ src-tauri/src/web/router.rs | 13 + src/components/chat/message-input.tsx | 199 ++++++++- src/hooks/use-composer-draft-sync.ts | 75 ++-- src/lib/api.ts | 30 +- src/lib/composer-draft-attachments.ts | 130 ++++++ src/lib/composer-draft-sync.test.ts | 96 +++++ src/lib/types.ts | 11 + 16 files changed, 1156 insertions(+), 50 deletions(-) create mode 100644 src-tauri/src/db/migration/m20260815_000003_composer_draft_attachments.rs create mode 100644 src/lib/composer-draft-attachments.ts diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 7db410a87..07a619f34 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -2033,9 +2033,16 @@ pub async fn put_composer_draft_core( conversation_id: i32, text: String, origin: String, + attachments: Option>, ) -> Result { - let result = - conversation_composer_draft_service::put(conn, conversation_id, text, origin).await?; + let result = conversation_composer_draft_service::put( + conn, + conversation_id, + text, + origin, + attachments, + ) + .await?; emit_event( emitter, COMPOSER_DRAFT_CHANGED_EVENT, @@ -2057,6 +2064,7 @@ pub async fn put_composer_draft( conversation_id: i32, text: String, origin: String, + attachments: Option>, ) -> Result { put_composer_draft_core( &db.conn, @@ -2064,6 +2072,34 @@ pub async fn put_composer_draft( conversation_id, text, origin, + attachments, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn read_upload_attachment( + path: String, +) -> Result { + crate::web::handlers::files::read_upload_attachment_core(path).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn stage_composer_attachment( + file_name: String, + mime_type: Option, + session_id: Option, + data_base64: String, +) -> Result { + crate::web::handlers::files::stage_composer_attachment_core( + crate::web::handlers::files::StageComposerAttachmentParams { + file_name, + mime_type, + session_id, + data_base64, + }, ) .await } diff --git a/src-tauri/src/db/entities/conversation_composer_draft.rs b/src-tauri/src/db/entities/conversation_composer_draft.rs index 07fb4de68..61c5de9df 100644 --- a/src-tauri/src/db/entities/conversation_composer_draft.rs +++ b/src-tauri/src/db/entities/conversation_composer_draft.rs @@ -10,6 +10,9 @@ pub struct Model { pub text: String, pub revision: i64, pub origin: String, + /// JSON array of jail / file-link refs. Never raw bytes. See the draft + /// service for the schema and the omit-does-not-wipe PUT contract. + pub attachments: String, pub updated_at: DateTimeUtc, } diff --git a/src-tauri/src/db/migration/m20260815_000003_composer_draft_attachments.rs b/src-tauri/src/db/migration/m20260815_000003_composer_draft_attachments.rs new file mode 100644 index 000000000..0e0539813 --- /dev/null +++ b/src-tauri/src/db/migration/m20260815_000003_composer_draft_attachments.rs @@ -0,0 +1,40 @@ +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 + .alter_table( + Table::alter() + .table(ConversationComposerDraft::Table) + .add_column_if_not_exists( + ColumnDef::new(ConversationComposerDraft::Attachments) + .text() + .not_null() + .default("[]"), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ConversationComposerDraft::Table) + .drop_column(ConversationComposerDraft::Attachments) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ConversationComposerDraft { + Table, + Attachments, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 5acd357fc..aca09d9da 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -38,6 +38,7 @@ mod m20260803_000001_token_usage; mod m20260807_000001_work_task_scheduled_at; mod m20260808_000001_custom_agent_supports_mcp; mod m20260815_000002_conversation_composer_draft; +mod m20260815_000003_composer_draft_attachments; pub struct Migrator; #[async_trait::async_trait] @@ -82,6 +83,7 @@ impl MigratorTrait for Migrator { Box::new(m20260807_000001_work_task_scheduled_at::Migration), Box::new(m20260808_000001_custom_agent_supports_mcp::Migration), Box::new(m20260815_000002_conversation_composer_draft::Migration), + Box::new(m20260815_000003_composer_draft_attachments::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_composer_draft_service.rs b/src-tauri/src/db/service/conversation_composer_draft_service.rs index 3d4ce1c5a..b7193c1d2 100644 --- a/src-tauri/src/db/service/conversation_composer_draft_service.rs +++ b/src-tauri/src/db/service/conversation_composer_draft_service.rs @@ -1,12 +1,19 @@ +use std::path::{Path, PathBuf}; + use chrono::Utc; use sea_orm::sea_query::OnConflict; use sea_orm::{DatabaseConnection, EntityTrait, Set}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::app_error::AppCommandError; use crate::db::entities::conversation_composer_draft; use crate::db::error::DbError; use crate::db::service::conversation_service; +use crate::paths::{codeg_uploads_root, simplify_verbatim_path}; + +/// Same ceiling as `UPLOAD_MAX_BYTES` in the upload handler. Kept local so +/// the draft table does not import the web layer. +const MAX_ATTACHMENT_BYTES: u64 = 20 * 1024 * 1024; /// Hard cap on persisted composer text. Matches a generous chat box, not a /// file upload. Enforced on UTF-8 byte length so a crafted payload cannot @@ -15,15 +22,42 @@ pub const MAX_DRAFT_BYTES: usize = 256 * 1024; /// Client origin id (uuid-ish). Stored + echoed on the WS notify; never a /// secret, just long enough to ignore our own broadcast. pub const MAX_ORIGIN_LEN: usize = 64; +/// Staged files on one draft. Matches the iOS chip cap and keeps the +/// metadata JSON small. +pub const MAX_DRAFT_ATTACHMENTS: usize = 16; + +/// Jail-ref or workspace-link metadata for one staged composer file. +/// Bytes never live here — images are `path`s under the uploads root; +/// workspace files are `file://` links the host agent can already read. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ComposerDraftAttachment { + pub id: String, + pub kind: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, +} + +fn is_zero(value: &u64) -> bool { + *value == 0 +} /// Full draft as returned by GET. `text` is the composer's visible string /// (not a Tiptap document) so mobile and desktop share one representation. +/// `attachments` is metadata only. #[derive(Debug, Clone, Serialize)] pub struct ComposerDraft { pub conversation_id: i32, pub text: String, pub revision: i64, pub origin: String, + pub attachments: Vec, } /// PUT result. Deliberately omits `text` so a log of the response cannot @@ -62,6 +96,194 @@ pub fn validate_text(text: &str) -> Result<(), AppCommandError> { Ok(()) } +fn parse_attachments_json(raw: &str) -> Vec { + if raw.trim().is_empty() || raw.trim() == "[]" { + return Vec::new(); + } + serde_json::from_str(raw).unwrap_or_default() +} + +fn encode_attachments(items: &[ComposerDraftAttachment]) -> Result { + serde_json::to_string(items).map_err(|e| { + AppCommandError::invalid_input("failed to encode composer draft attachments") + .with_detail(e.to_string()) + }) +} + +fn valid_id(id: &str) -> bool { + let len = id.len(); + (1..=80).contains(&len) + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'_' | b'-')) +} + +fn valid_name(name: &str) -> bool { + let len = name.chars().count(); + (1..=255).contains(&len) + && !name.contains('/') + && !name.contains('\\') + && !name.contains('\0') +} + +fn valid_mime(mime: &str) -> bool { + let len = mime.len(); + if !(1..=127).contains(&len) { + return false; + } + let Some((typ, sub)) = mime.split_once('/') else { + return false; + }; + !typ.is_empty() + && !sub.is_empty() + && typ + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'+') + && sub + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'+') +} + +fn valid_file_uri(uri: &str) -> bool { + if uri.len() > 2048 || !uri.starts_with("file://") { + return false; + } + // Reject authority-form `file://host/share` (SMB / UNC). Same reason + // prompt hydration refuses it: canonicalize would connect out. + let rest = &uri["file://".len()..]; + if rest.starts_with('/') { + return true; + } + rest.starts_with("?/") || rest.is_empty() +} + +/// True when `candidate` is a regular file inside `uploads_root`. +pub async fn path_is_inside_uploads( + candidate: &Path, + uploads_root: &Path, +) -> Result { + let candidate_canon = tokio::fs::canonicalize(candidate).await.map_err(|_| { + AppCommandError::invalid_input("draft attachment path is not a readable file") + })?; + let root_canon = tokio::fs::canonicalize(uploads_root).await.map_err(|_| { + AppCommandError::invalid_input("uploads directory is not accessible") + })?; + if !candidate_canon.starts_with(&root_canon) { + return Err(AppCommandError::invalid_input( + "draft attachment path is outside the uploads directory", + )); + } + let meta = tokio::fs::metadata(&candidate_canon).await.map_err(|_| { + AppCommandError::invalid_input("draft attachment path is not a readable file") + })?; + if !meta.is_file() { + return Err(AppCommandError::invalid_input( + "draft attachment path is not a regular file", + )); + } + if meta.len() > MAX_ATTACHMENT_BYTES { + return Err(AppCommandError::invalid_input( + "draft attachment exceeds the upload size limit", + )); + } + Ok(simplify_verbatim_path(&candidate_canon)) +} + +pub async fn validate_attachments( + items: &[ComposerDraftAttachment], + uploads_root: &Path, +) -> Result, AppCommandError> { + if items.len() > MAX_DRAFT_ATTACHMENTS { + return Err(AppCommandError::invalid_input( + "composer draft has too many attachments", + )); + } + let mut out = Vec::with_capacity(items.len()); + let mut seen = std::collections::HashSet::new(); + for item in items { + if !valid_id(&item.id) { + return Err(AppCommandError::invalid_input( + "draft attachment id is invalid", + )); + } + if !seen.insert(item.id.clone()) { + return Err(AppCommandError::invalid_input( + "draft attachment ids must be unique", + )); + } + if !valid_name(&item.name) { + return Err(AppCommandError::invalid_input( + "draft attachment name is invalid", + )); + } + if let Some(mime) = item.mime.as_deref() { + if !valid_mime(mime) { + return Err(AppCommandError::invalid_input( + "draft attachment mime type is invalid", + )); + } + } + if item.size > MAX_ATTACHMENT_BYTES { + return Err(AppCommandError::invalid_input( + "draft attachment exceeds the upload size limit", + )); + } + match item.kind.as_str() { + "image" => { + let Some(path) = item.path.as_deref().map(str::trim).filter(|s| !s.is_empty()) + else { + return Err(AppCommandError::invalid_input( + "image draft attachments need an uploads path", + )); + }; + if path.len() > 1024 { + return Err(AppCommandError::invalid_input( + "draft attachment path is too long", + )); + } + let checked = path_is_inside_uploads(Path::new(path), uploads_root).await?; + out.push(ComposerDraftAttachment { + id: item.id.clone(), + kind: "image".into(), + name: item.name.clone(), + mime: item.mime.clone(), + size: item.size, + path: Some(checked.to_string_lossy().into_owned()), + uri: None, + }); + } + "file" => { + let Some(uri) = item.uri.as_deref().map(str::trim).filter(|s| !s.is_empty()) + else { + return Err(AppCommandError::invalid_input( + "file draft attachments need a file:// uri", + )); + }; + if !valid_file_uri(uri) { + return Err(AppCommandError::invalid_input( + "draft attachment uri is invalid", + )); + } + out.push(ComposerDraftAttachment { + id: item.id.clone(), + kind: "file".into(), + name: item.name.clone(), + mime: item.mime.clone(), + size: item.size, + path: None, + uri: Some(uri.to_string()), + }); + } + _ => { + return Err(AppCommandError::invalid_input( + "draft attachment kind must be image or file", + )); + } + } + } + Ok(out) +} + pub async fn get( conn: &DatabaseConnection, conversation_id: i32, @@ -78,6 +300,7 @@ pub async fn get( text: row.text, revision: row.revision, origin: row.origin, + attachments: parse_attachments_json(&row.attachments), })) } @@ -85,11 +308,16 @@ pub async fn get( /// clients can ignore their own echo and apply only newer remote revisions. /// An empty `text` still writes a row (a tombstone) so a clear on one /// device can wipe the other. +/// +/// `attachments` is optional on purpose: an older client that only sends +/// text must not wipe images/files the other side already staged. `None` +/// keeps the stored list. `Some` (including empty) replaces it. pub async fn put( conn: &DatabaseConnection, conversation_id: i32, text: String, origin: String, + attachments: Option>, ) -> Result { validate_origin(&origin)?; validate_text(&text)?; @@ -101,14 +329,29 @@ pub async fn put( .one(conn) .await .map_err(AppCommandError::from)?; - let next_revision = current.map(|row| row.revision + 1).unwrap_or(1); - let cleared = text.is_empty(); + let next_revision = current + .as_ref() + .map(|row| row.revision + 1) + .unwrap_or(1); + let kept_attachments = current + .as_ref() + .map(|row| row.attachments.clone()) + .unwrap_or_else(|| "[]".into()); + let stored_attachments = if let Some(items) = attachments { + let checked = validate_attachments(&items, &codeg_uploads_root()).await?; + encode_attachments(&checked)? + } else { + kept_attachments + }; + let attachments_empty = parse_attachments_json(&stored_attachments).is_empty(); + let cleared = text.is_empty() && attachments_empty; let now = Utc::now(); let model = conversation_composer_draft::ActiveModel { conversation_id: Set(conversation_id), text: Set(text), revision: Set(next_revision), origin: Set(origin.clone()), + attachments: Set(stored_attachments), updated_at: Set(now), }; conversation_composer_draft::Entity::insert(model) @@ -118,6 +361,7 @@ pub async fn put( conversation_composer_draft::Column::Text, conversation_composer_draft::Column::Revision, conversation_composer_draft::Column::Origin, + conversation_composer_draft::Column::Attachments, conversation_composer_draft::Column::UpdatedAt, ]) .to_owned(), @@ -140,6 +384,18 @@ mod tests { use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; use crate::models::agent::AgentType; + fn file_att(id: &str, name: &str, uri: &str) -> ComposerDraftAttachment { + ComposerDraftAttachment { + id: id.into(), + kind: "file".into(), + name: name.into(), + mime: Some("text/plain".into()), + size: 0, + path: None, + uri: Some(uri.into()), + } + } + #[test] fn origin_rejects_empty_long_and_junk() { assert!(validate_origin("").is_err()); @@ -154,6 +410,14 @@ mod tests { assert!(validate_text(&"x".repeat(MAX_DRAFT_BYTES + 1)).is_err()); } + #[test] + fn file_uri_rejects_unc_authority() { + assert!(valid_file_uri("file:///C:/notes.md")); + assert!(valid_file_uri("file:///tmp/notes.md")); + assert!(!valid_file_uri("file://host/share/notes.md")); + assert!(!valid_file_uri("https://evil.example/x")); + } + #[tokio::test] async fn put_get_round_trip_and_clear() { let db = fresh_in_memory_db().await; @@ -162,9 +426,15 @@ mod tests { assert!(get(&db.conn, id).await.unwrap().is_none()); - let first = put(&db.conn, id, "hello from desktop".into(), "desk1".into()) - .await - .unwrap(); + let first = put( + &db.conn, + id, + "hello from desktop".into(), + "desk1".into(), + None, + ) + .await + .unwrap(); assert_eq!(first.revision, 1); assert!(!first.cleared); @@ -172,28 +442,124 @@ mod tests { assert_eq!(loaded.text, "hello from desktop"); assert_eq!(loaded.origin, "desk1"); assert_eq!(loaded.revision, 1); + assert!(loaded.attachments.is_empty()); - let second = put(&db.conn, id, "hello from phone".into(), "phone1".into()) - .await - .unwrap(); + let second = put( + &db.conn, + id, + "hello from phone".into(), + "phone1".into(), + None, + ) + .await + .unwrap(); assert_eq!(second.revision, 2); let loaded = get(&db.conn, id).await.unwrap().unwrap(); assert_eq!(loaded.text, "hello from phone"); assert_eq!(loaded.origin, "phone1"); - let cleared = put(&db.conn, id, String::new(), "phone1".into()) + let cleared = put(&db.conn, id, String::new(), "phone1".into(), Some(Vec::new())) .await .unwrap(); assert!(cleared.cleared); assert_eq!(cleared.revision, 3); let loaded = get(&db.conn, id).await.unwrap().unwrap(); assert_eq!(loaded.text, ""); + assert!(loaded.attachments.is_empty()); + } + + #[tokio::test] + async fn omit_attachments_does_not_wipe() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/draft-att").await; + let id = seed_conversation(&db, folder, AgentType::Grok).await; + + let file = file_att("file:notes", "notes.md", "file:///tmp/notes.md"); + put( + &db.conn, + id, + "see notes".into(), + "desk1".into(), + Some(vec![file.clone()]), + ) + .await + .unwrap(); + + // Old client: text only. The file chip must survive. + put(&db.conn, id, "see notes, edited".into(), "phone1".into(), None) + .await + .unwrap(); + let loaded = get(&db.conn, id).await.unwrap().unwrap(); + assert_eq!(loaded.text, "see notes, edited"); + assert_eq!(loaded.attachments, vec![file]); + + // New client sending [] is an explicit clear. + put( + &db.conn, + id, + "cleared chips".into(), + "desk1".into(), + Some(Vec::new()), + ) + .await + .unwrap(); + let loaded = get(&db.conn, id).await.unwrap().unwrap(); + assert!(loaded.attachments.is_empty()); + assert_eq!(loaded.text, "cleared chips"); + } + + #[tokio::test] + async fn rejects_too_many_and_bad_kind() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/draft-att-bad").await; + let id = seed_conversation(&db, folder, AgentType::Grok).await; + + let too_many: Vec<_> = (0..MAX_DRAFT_ATTACHMENTS + 1) + .map(|i| file_att(&format!("file:{i}"), "n.md", "file:///tmp/n.md")) + .collect(); + assert!(put(&db.conn, id, "x".into(), "desk1".into(), Some(too_many)) + .await + .is_err()); + + let bad = ComposerDraftAttachment { + id: "x".into(), + kind: "blob".into(), + name: "x.bin".into(), + mime: None, + size: 0, + path: None, + uri: Some("file:///tmp/x.bin".into()), + }; + assert!(put(&db.conn, id, "x".into(), "desk1".into(), Some(vec![bad])) + .await + .is_err()); + } + + #[tokio::test] + async fn image_without_jail_path_is_rejected() { + let items = [ComposerDraftAttachment { + id: "image:1".into(), + kind: "image".into(), + name: "shot.png".into(), + mime: Some("image/png".into()), + size: 10, + path: None, + uri: Some("file:///tmp/shot.png".into()), + }]; + let err = validate_attachments(&items, Path::new("/tmp")) + .await + .unwrap_err(); + assert!( + err.message.contains("uploads path"), + "got {}", + err.message + ); } #[tokio::test] async fn put_requires_a_live_conversation() { let db = fresh_in_memory_db().await; - let err = put(&db.conn, 999_999, "x".into(), "desk1".into()) + let err = put(&db.conn, 999_999, "x".into(), "desk1".into(), None) .await .unwrap_err(); let detail = err.detail.unwrap_or_default(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae84d95e4..48d702edf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -990,6 +990,8 @@ mod tauri_app { conversations::update_conversation_pinned, conversations::get_composer_draft, conversations::put_composer_draft, + conversations::read_upload_attachment, + conversations::stage_composer_attachment, conversations::delete_conversation, folders::load_folder_history, folders::get_folder, diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index 225ba55ec..97c7a4048 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -653,6 +653,10 @@ mod tests { assert_eq!(p["origin"], "phone1"); assert_eq!(p["cleared"], false); assert!(p.get("text").is_none(), "notify must never carry the draft body"); + assert!( + p.get("attachments").is_none(), + "notify must never carry attachment metadata or bytes" + ); } #[test] diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 6e9658675..215776bfe 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -391,6 +391,11 @@ pub struct PutComposerDraftParams { pub conversation_id: i32, pub text: String, pub origin: String, + /// Absent / null = keep the stored list (old clients). `[]` clears. + #[serde(default)] + pub attachments: Option< + Vec, + >, } pub async fn put_composer_draft( @@ -407,6 +412,7 @@ pub async fn put_composer_draft( params.conversation_id, params.text, params.origin, + params.attachments, ) .await?, )) diff --git a/src-tauri/src/web/handlers/files.rs b/src-tauri/src/web/handlers/files.rs index 9c5bee6fe..5a66ca856 100644 --- a/src-tauri/src/web/handlers/files.rs +++ b/src-tauri/src/web/handlers/files.rs @@ -5,8 +5,11 @@ use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use std::collections::BTreeMap; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; +use base64::Engine; + use crate::app_error::{ AppCommandError, UPLOAD_I18N_KEY_QUOTA_EXCEEDED, UPLOAD_I18N_KEY_TOO_LARGE, }; @@ -1033,6 +1036,170 @@ async fn stream_and_finalize( }) } +// --------------------------------------------------------------------------- +// Draft-attachment read / local stage +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadUploadAttachmentParams { + pub path: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadUploadAttachmentResult { + pub data: String, + pub name: String, + pub size: u64, + pub mime_type: Option, +} + +/// Authenticated read of one file that already lives in the uploads jail. +/// Used by the other device to hydrate a draft-image thumbnail. Arbitrary +/// workspace paths are refused — that is `read_file_base64`, not this. +pub async fn read_upload_attachment_core( + path: String, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(AppCommandError::invalid_input("Path cannot be empty")); + } + let uploads_root = codeg_uploads_root(); + let checked = crate::db::service::conversation_composer_draft_service::path_is_inside_uploads( + Path::new(trimmed), + &uploads_root, + ) + .await?; + let bytes = tokio::fs::read(&checked).await.map_err(|e| { + AppCommandError::io_error("Failed to read uploaded attachment").with_detail(e.to_string()) + })?; + let name = checked + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file") + .to_string(); + Ok(ReadUploadAttachmentResult { + size: bytes.len() as u64, + data: base64::engine::general_purpose::STANDARD.encode(&bytes), + name, + mime_type: None, + }) +} + +pub async fn read_upload_attachment( + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json(read_upload_attachment_core(params.path).await?)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StageComposerAttachmentParams { + pub file_name: String, + pub mime_type: Option, + pub session_id: Option, + pub data_base64: String, +} + +/// Copy in-memory bytes into the uploads jail (Tauri local desktop). Web +/// clients already go through `/upload_attachment`; this exists so a +/// desktop-local image can be fetched by the phone. +pub async fn stage_composer_attachment_core( + params: StageComposerAttachmentParams, +) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(params.data_base64.trim()) + .map_err(|_| AppCommandError::invalid_input("attachment payload is not valid base64"))?; + if bytes.is_empty() { + return Err(AppCommandError::io_error("Uploaded file is empty")); + } + if bytes.len() as u64 > UPLOAD_MAX_BYTES { + let mut i18n_params = BTreeMap::new(); + i18n_params.insert("size".to_string(), bytes.len().to_string()); + i18n_params.insert("limit".to_string(), UPLOAD_MAX_BYTES.to_string()); + return Err(AppCommandError::io_error("Upload exceeds the maximum allowed size") + .with_detail(format!("size={} limit={UPLOAD_MAX_BYTES}", bytes.len())) + .with_i18n(UPLOAD_I18N_KEY_TOO_LARGE, i18n_params)); + } + + let uploads_root = codeg_uploads_root(); + tokio::fs::create_dir_all(&uploads_root).await.map_err(|e| { + AppCommandError::io_error("Failed to create uploads root").with_detail(e.to_string()) + })?; + let tmp_dir = uploads_root.join(".tmp"); + tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| { + AppCommandError::io_error("Failed to create tmp directory").with_detail(e.to_string()) + })?; + ensure_path_inside(&tmp_dir, &uploads_root).await?; + + let staging_id = uuid::Uuid::new_v4().simple().to_string(); + let staging_name = format!("{staging_id}.part"); + let mut out = upload_jail::create_staging_file(&tmp_dir, &staging_name) + .await + .map_err(|e| { + AppCommandError::io_error("Failed to create staging file").with_detail(e.to_string()) + })?; + if let Err(err) = out.write_all(&bytes).await { + upload_jail::remove_staging_best_effort(&tmp_dir, &staging_name).await; + return Err(AppCommandError::io_error("Failed to write staged attachment") + .with_detail(err.to_string())); + } + if let Err(err) = out.flush().await { + upload_jail::remove_staging_best_effort(&tmp_dir, &staging_name).await; + return Err( + AppCommandError::io_error("Failed to flush staged attachment").with_detail(err.to_string()) + ); + } + drop(out); + + let safe_name = sanitize_upload_filename(¶ms.file_name); + let bucket = sanitize_session_bucket(params.session_id.as_deref().unwrap_or("draft")); + let dir = uploads_root.join(&bucket); + tokio::fs::create_dir_all(&dir).await.map_err(|e| { + AppCommandError::io_error("Failed to create uploads directory").with_detail(e.to_string()) + })?; + if let Err(err) = ensure_path_inside(&dir, &uploads_root).await { + upload_jail::remove_staging_best_effort(&tmp_dir, &staging_name).await; + return Err(err); + } + + let final_name = match finalize_with_available_upload_name( + &tmp_dir, + &staging_name, + &dir, + &safe_name, + ) + .await + { + Ok(name) => name, + Err(err) => { + upload_jail::remove_staging_best_effort(&tmp_dir, &staging_name).await; + return Err(err); + } + }; + let final_path = dir.join(&final_name); + let canon = match ensure_path_inside(&final_path, &uploads_root).await { + Ok(p) => p, + Err(err) => { + let _ = tokio::fs::remove_file(&final_path).await; + return Err(err); + } + }; + Ok(UploadAttachmentResult { + path: simplify_verbatim_path(&canon).to_string_lossy().to_string(), + name: final_name, + size: bytes.len() as u64, + mime_type: params.mime_type, + }) +} + +pub async fn stage_composer_attachment( + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json(stage_composer_attachment_core(params).await?)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 8b64fe0db..a254b3dbb 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -461,6 +461,19 @@ pub fn build_router( post(handlers::files::upload_attachment) .layer(DefaultBodyLimit::max(UPLOAD_MAX_BYTES as usize + 64 * 1024)), ) + .route( + "/read_upload_attachment", + post(handlers::files::read_upload_attachment), + ) + .route( + "/stage_composer_attachment", + // Local desktop stages draft images as JSON base64 over IPC or + // the web API. Pad to the same ceiling as multipart upload. + post(handlers::files::stage_composer_attachment) + .layer(DefaultBodyLimit::max( + ((UPLOAD_MAX_BYTES as usize * 4) / 3) + 64 * 1024, + )), + ) // ─── Workspace files (web upload/download) ─── // // Issue #179: when codeg runs in server mode the user has no diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 451b761d5..0da8369d2 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -98,7 +98,20 @@ import { saveMessageInputDraftV2, } from "@/lib/message-input-draft" import { conversationIdFromDraftKey } from "@/lib/composer-draft-sync" +import { + collectDraftAttachments, + fileUriToPath, + isUploadJailPath, +} from "@/lib/composer-draft-attachments" import { useComposerDraftSync } from "@/hooks/use-composer-draft-sync" +import { + readUploadAttachment, + stageComposerAttachment, + uploadAttachment, +} from "@/lib/api" +import { buildFileUri } from "@/lib/reference-link" +import { getActiveRemoteConnectionId, isDesktop } from "@/lib/transport" +import type { ComposerDraftAttachment } from "@/lib/types" import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { RichComposer, @@ -336,17 +349,36 @@ export function MessageInput({ effectiveDraftStorageKey ) const applyingRemoteDraftRef = useRef(false) + const attachRef = useRef<{ + attachments: ReturnType["attachments"] + setAttachments: ReturnType["setAttachments"] + insertFileReferences: ReturnType< + typeof useComposerAttachments + >["insertFileReferences"] + } | null>(null) + const applyRemoteDraftAttachmentsRef = useRef< + (items: ComposerDraftAttachment[]) => Promise + >(async () => {}) const persistSyncedDraft = useComposerDraftSync({ conversationId: syncedConversationId, enabled: !isEditingQueueItem && composerReady, - getLocalText: () => editorRef.current?.getText() ?? "", - onRemote: (text) => { + getLocalSnapshot: () => ({ + text: editorRef.current?.getText() ?? "", + attachments: collectDraftAttachments( + editorRef.current?.getEditor()?.getJSON(), + attachRef.current?.attachments ?? [] + ), + }), + onRemote: async (snapshot) => { const ed = editorRef.current if (!ed) return applyingRemoteDraftRef.current = true - ed.setText(text) + ed.setText(snapshot.text) const editor = ed.getEditor() - setComposerEmpty(editor ? isComposerEmpty(editor) : text.length === 0) + setComposerEmpty( + editor ? isComposerEmpty(editor) : snapshot.text.length === 0 + ) + await applyRemoteDraftAttachmentsRef.current(snapshot.attachments) if (typeof window !== "undefined") { window.setTimeout(() => { applyingRemoteDraftRef.current = false @@ -377,12 +409,101 @@ export function MessageInput({ }) const { attachments, + setAttachments, embeddedPayloadsRef, clearAttachments, hasUploadingImage, hydrateFromBlocks, imagePromptBlocks, + insertFileReferences, } = attach + attachRef.current = { + attachments, + setAttachments, + insertFileReferences, + } + + const persistDraftSnapshot = useCallback(async () => { + if (applyingRemoteDraftRef.current) return + if (hasUploadingImage) return + const ed = editorRef.current + const text = ed?.getText() ?? "" + const imageItems = attachments.filter((item) => item.type === "image") + const staged = await Promise.all( + imageItems.map(async (image) => { + const existing = image.uri ? fileUriToPath(image.uri) : null + if (existing && isUploadJailPath(existing)) { + return image + } + try { + const uploaded = await stageLocalDraftImage( + image, + attachmentTabId ?? null + ) + return uploaded + } catch (error) { + console.warn( + `[MessageInput] draft image stage failed (${image.name}):`, + error + ) + return image + } + }) + ) + const changed = staged.some((item, index) => item !== imageItems[index]) + if (changed) { + setAttachments((prev) => + prev.map((item) => { + if (item.type !== "image") return item + return staged.find((next) => next.id === item.id) ?? item + }) + ) + } + persistSyncedDraft({ + text, + attachments: collectDraftAttachments(ed?.getEditor()?.getJSON(), staged), + }) + }, [ + attachments, + attachmentTabId, + hasUploadingImage, + persistSyncedDraft, + setAttachments, + ]) + applyRemoteDraftAttachmentsRef.current = async (items) => { + const images = items.filter((item) => item.kind === "image" && item.path) + const files = items.filter((item) => item.kind === "file" && item.uri) + const hydrated = await Promise.all( + images.map(async (item) => { + try { + const read = await readUploadAttachment(item.path as string) + return { + id: item.id, + type: "image" as const, + data: read.data, + uri: buildFileUri(item.path as string), + name: item.name, + mimeType: item.mime || read.mimeType || "image/png", + } + } catch (error) { + console.warn( + `[MessageInput] draft image hydrate failed (${item.name}):`, + error + ) + return null + } + }) + ) + setAttachments(hydrated.filter((item) => item !== null)) + if (files.length > 0) { + insertFileReferences( + files.map((item) => ({ + name: item.name, + uri: item.uri as string, + })) + ) + } + } const menuShortcuts = useComposerShortcuts({ editorRef, agentType: agentType ?? null, @@ -460,18 +581,27 @@ export function MessageInput({ draftSaveTimerRef.current = null const ed = editorRef.current if (!ed || !effectiveDraftStorageKey) return - if (ed.isEmpty()) { + if (ed.isEmpty() && (attachRef.current?.attachments.length ?? 0) === 0) { clearMessageInputDraftV2(effectiveDraftStorageKey) - if (!applyingRemoteDraftRef.current) persistSyncedDraft("") + if (!applyingRemoteDraftRef.current) { + persistSyncedDraft({ text: "", attachments: [] }) + } } else { saveMessageInputDraftV2( effectiveDraftStorageKey, stripEmbeddedReferences(ed.getJSON()) ) - if (!applyingRemoteDraftRef.current) persistSyncedDraft(ed.getText()) + if (!applyingRemoteDraftRef.current) { + void persistDraftSnapshot() + } } }, 300) - }, [effectiveDraftStorageKey, isEditingQueueItem, persistSyncedDraft]) + }, [ + effectiveDraftStorageKey, + isEditingQueueItem, + persistSyncedDraft, + persistDraftSnapshot, + ]) useEffect(() => { return () => { @@ -481,6 +611,19 @@ export function MessageInput({ } }, []) + useEffect(() => { + if (!composerReady || isEditingQueueItem) return + if (applyingRemoteDraftRef.current) return + if (hasUploadingImage) return + void persistDraftSnapshot() + }, [ + attachments, + composerReady, + hasUploadingImage, + isEditingQueueItem, + persistDraftSnapshot, + ]) + // One-time hydration once the editor is ready: a queue-edit payload, else a v2 // draft document (or a legacy v1 Markdown draft migrated forward). Guarded so // it never re-runs and clobbers later user edits. @@ -1153,7 +1296,7 @@ export function MessageInput({ if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) } - persistSyncedDraft("") + persistSyncedDraft({ text: "", attachments: [] }) resetComposer() return } @@ -1162,7 +1305,7 @@ export function MessageInput({ if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) } - persistSyncedDraft("") + persistSyncedDraft({ text: "", attachments: [] }) resetComposer() }, [ disabled, @@ -1199,7 +1342,7 @@ export function MessageInput({ onForkSend(draft, showModeSelector ? effectiveModeId : null) if (effectiveDraftStorageKey) { clearMessageInputDraftV2(effectiveDraftStorageKey) - persistSyncedDraft("") + persistSyncedDraft({ text: "", attachments: [] }) } resetComposer() }, [ @@ -2039,3 +2182,37 @@ export function MessageInput({ ) } + +async function stageLocalDraftImage( + image: { + id: string + data: string + name: string + mimeType: string + uri: string | null + }, + sessionId: string | null +) { + const localDesktop = isDesktop() && getActiveRemoteConnectionId() === null + if (localDesktop) { + const staged = await stageComposerAttachment({ + fileName: image.name, + mimeType: image.mimeType, + sessionId, + dataBase64: image.data, + }) + return { + ...image, + type: "image" as const, + uri: buildFileUri(staged.path), + } + } + const binary = Uint8Array.from(atob(image.data), (char) => char.charCodeAt(0)) + const file = new File([binary], image.name, { type: image.mimeType }) + const uploaded = await uploadAttachment(file, sessionId) + return { + ...image, + type: "image" as const, + uri: buildFileUri(uploaded.path), + } +} diff --git a/src/hooks/use-composer-draft-sync.ts b/src/hooks/use-composer-draft-sync.ts index bc1978d7b..e45f76aa0 100644 --- a/src/hooks/use-composer-draft-sync.ts +++ b/src/hooks/use-composer-draft-sync.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react" import { getComposerDraft, putComposerDraft } from "@/lib/api" +import { draftSnapshotKey } from "@/lib/composer-draft-attachments" import { getComposerClientOrigin, shouldApplyRemoteDraft, @@ -10,28 +11,39 @@ import { import { onTransportReconnect, subscribe } from "@/lib/platform" import { COMPOSER_DRAFT_CHANGED_EVENT, + type ComposerDraftAttachment, type ComposerDraftChanged, } from "@/lib/types" +export interface ComposerDraftSnapshot { + text: string + attachments: ComposerDraftAttachment[] +} + /** Two-way unsent-composer sync for a persisted conversation. * * GET on open / reconnect. PUT after the local draft save (caller). WS * notify is ids-only; the body is fetched over the authenticated GET. - * Own-origin echoes are ignored so typing does not bounce. */ + * Own-origin echoes are ignored so typing does not bounce. + * + * Attachments are jail/file refs, never bytes. A client that has not + * finished the first GET omits the field so it cannot wipe the other + * side's images. After that, the full snapshot is last-write-wins. */ export function useComposerDraftSync(opts: { conversationId: number | null enabled: boolean - getLocalText: () => string - onRemote: (text: string) => void -}): (text: string) => void { - const { conversationId, enabled, getLocalText, onRemote } = opts + getLocalSnapshot: () => ComposerDraftSnapshot + onRemote: (snapshot: ComposerDraftSnapshot) => void | Promise +}): (snapshot: ComposerDraftSnapshot) => void { + const { conversationId, enabled, getLocalSnapshot, onRemote } = opts const originRef = useRef(getComposerClientOrigin()) const lastRevisionRef = useRef(0) - const lastPutTextRef = useRef(null) + const lastPutKeyRef = useRef(null) + const hydratedRef = useRef(false) const onRemoteRef = useRef(onRemote) - const getLocalTextRef = useRef(getLocalText) + const getLocalSnapshotRef = useRef(getLocalSnapshot) onRemoteRef.current = onRemote - getLocalTextRef.current = getLocalText + getLocalSnapshotRef.current = getLocalSnapshot const applyIfNewer = useCallback( async (hint?: ComposerDraftChanged) => { @@ -53,19 +65,21 @@ export function useComposerDraftSync(opts: { try { const remote = await getComposerDraft(conversationId) if (!remote) { - // No server row yet: publish this device's local cache so a - // phone that opens the same chat can GET it. Skip when the - // box is empty so we do not create tombstones for idle tabs. + hydratedRef.current = true if (!hint) { - const local = getLocalTextRef.current() - if (local) { - lastPutTextRef.current = null + const local = getLocalSnapshotRef.current() + if (local.text || local.attachments.length > 0) { + lastPutKeyRef.current = null void putComposerDraft( conversationId, - local, - originRef.current + local.text, + originRef.current, + local.attachments ).then((result) => { - lastPutTextRef.current = local + lastPutKeyRef.current = draftSnapshotKey( + local.text, + local.attachments + ) lastRevisionRef.current = Math.max( lastRevisionRef.current, result.revision @@ -87,13 +101,17 @@ export function useComposerDraftSync(opts: { lastRevisionRef.current, remote.revision ) + hydratedRef.current = true return } lastRevisionRef.current = remote.revision - lastPutTextRef.current = remote.text - onRemoteRef.current(remote.text) + const attachments = remote.attachments ?? [] + lastPutKeyRef.current = draftSnapshotKey(remote.text, attachments) + hydratedRef.current = true + await onRemoteRef.current({ text: remote.text, attachments }) } catch { // Draft sync is best-effort. A missed GET leaves the local box as-is. + hydratedRef.current = true } }, [conversationId, enabled] @@ -101,7 +119,8 @@ export function useComposerDraftSync(opts: { useEffect(() => { lastRevisionRef.current = 0 - lastPutTextRef.current = null + lastPutKeyRef.current = null + hydratedRef.current = false if (!enabled || conversationId == null) return void applyIfNewer() }, [applyIfNewer, conversationId, enabled]) @@ -135,11 +154,17 @@ export function useComposerDraftSync(opts: { }, [applyIfNewer, conversationId, enabled]) return useCallback( - (text: string) => { + (snapshot: ComposerDraftSnapshot) => { if (!enabled || conversationId == null) return - if (lastPutTextRef.current === text) return - lastPutTextRef.current = text - void putComposerDraft(conversationId, text, originRef.current) + const key = draftSnapshotKey(snapshot.text, snapshot.attachments) + if (lastPutKeyRef.current === key) return + lastPutKeyRef.current = key + void putComposerDraft( + conversationId, + snapshot.text, + originRef.current, + hydratedRef.current ? snapshot.attachments : undefined + ) .then((result) => { lastRevisionRef.current = Math.max( lastRevisionRef.current, @@ -147,7 +172,7 @@ export function useComposerDraftSync(opts: { ) }) .catch(() => { - // Leave lastPutText so we do not tight-loop a failing PUT. + // Leave lastPutKey so we do not tight-loop a failing PUT. }) }, [conversationId, enabled] diff --git a/src/lib/api.ts b/src/lib/api.ts index 4d91ca542..bb68a21a6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -29,6 +29,7 @@ import type { ConversationSummary, ConversationDetail, ComposerDraft, + ComposerDraftAttachment, ComposerDraftPutResult, ConversationTurnsPage, DbConversationDetail, @@ -2847,12 +2848,39 @@ export async function getComposerDraft( export async function putComposerDraft( conversationId: number, text: string, - origin: string + origin: string, + attachments?: ComposerDraftAttachment[] ): Promise { return getTransport().call("put_composer_draft", { conversationId, text, origin, + ...(attachments !== undefined ? { attachments } : {}), + }) +} + +export async function readUploadAttachment( + path: string +): Promise<{ + data: string + name: string + size: number + mimeType?: string | null +}> { + return getTransport().call("read_upload_attachment", { path }) +} + +export async function stageComposerAttachment(opts: { + fileName: string + mimeType?: string | null + sessionId?: string | null + dataBase64: string +}): Promise { + return getTransport().call("stage_composer_attachment", { + fileName: opts.fileName, + mimeType: opts.mimeType ?? null, + sessionId: opts.sessionId ?? null, + dataBase64: opts.dataBase64, }) } diff --git a/src/lib/composer-draft-attachments.ts b/src/lib/composer-draft-attachments.ts new file mode 100644 index 000000000..82e363766 --- /dev/null +++ b/src/lib/composer-draft-attachments.ts @@ -0,0 +1,130 @@ +import type { JSONContent } from "@tiptap/core" + +import type { ImageInputAttachment } from "@/components/chat/message-input-attachments" +import type { ComposerDraftAttachment } from "@/lib/types" + +const FILE_URI_PREFIX = "file://" + +/** Fingerprint used to skip a PUT that would write the same snapshot. */ +export function draftSnapshotKey( + text: string, + attachments: ComposerDraftAttachment[] +): string { + return JSON.stringify({ + text, + attachments: attachments.map((item) => ({ + id: item.id, + kind: item.kind, + name: item.name, + mime: item.mime ?? null, + size: item.size ?? 0, + path: item.path ?? null, + uri: item.uri ?? null, + })), + }) +} + +export function attachmentsEqual( + a: ComposerDraftAttachment[], + b: ComposerDraftAttachment[] +): boolean { + return draftSnapshotKey("", a) === draftSnapshotKey("", b) +} + +/** Decode a `file://` uri the composer built via `buildFileUri`. */ +export function fileUriToPath(uri: string | null | undefined): string | null { + if (!uri || !uri.startsWith(FILE_URI_PREFIX)) return null + const rest = uri.slice(FILE_URI_PREFIX.length).split("#")[0] + let decoded: string + try { + decoded = decodeURIComponent(rest) + } catch { + return null + } + if (!decoded) return null + // `file:///C:/x` → `C:/x` on Windows; unix stays `/x`. + if (/^\/[A-Za-z]:\//.test(decoded)) return decoded.slice(1) + return decoded +} + +/** Best-effort: a path that already lives in the uploads jail. */ +export function isUploadJailPath(path: string | null | undefined): boolean { + if (!path) return false + const normalized = path.replace(/\\/g, "/").toLowerCase() + return ( + normalized.includes("/.codeg/uploads/") || + /\/uploads\/[a-z0-9_-]+\//i.test(normalized) + ) +} + +/** Pull workspace `file://` badges out of a Tiptap document. */ +export function collectEditorFileAttachments( + doc: JSONContent | null | undefined +): ComposerDraftAttachment[] { + if (!doc) return [] + const out: ComposerDraftAttachment[] = [] + const seen = new Set() + walk(doc, (node) => { + if (node.type !== "reference") return + const attrs = node.attrs ?? {} + if (attrs.refType !== "file") return + const uri = typeof attrs.uri === "string" ? attrs.uri : "" + if (!uri.startsWith(FILE_URI_PREFIX)) return + if (seen.has(uri)) return + seen.add(uri) + const name = + (typeof attrs.label === "string" && attrs.label.trim()) || + fileNameFromUri(uri) + out.push({ + id: `file:${uri}`, + kind: "file", + name, + mime: null, + size: 0, + path: null, + uri, + }) + }) + return out +} + +function fileNameFromUri(uri: string): string { + const path = fileUriToPath(uri) ?? uri + const parts = path.split(/[/\\]/) + return parts[parts.length - 1] || "file" +} + +function walk(node: JSONContent, visit: (node: JSONContent) => void): void { + visit(node) + for (const child of node.content ?? []) walk(child, visit) +} + +export function collectDraftAttachments( + doc: JSONContent | null | undefined, + images: ImageInputAttachment[] +): ComposerDraftAttachment[] { + const staged = images + .map(imageToDraftAttachment) + .filter((item): item is ComposerDraftAttachment => item !== null) + const files = collectEditorFileAttachments(doc) + const seen = new Set(staged.map((item) => item.uri ?? item.path ?? item.id)) + const extra = files.filter((item) => !seen.has(item.uri ?? item.id)) + return [...staged, ...extra] +} + +export function imageToDraftAttachment( + attachment: ImageInputAttachment +): ComposerDraftAttachment | null { + if (attachment.uploading) return null + const path = fileUriToPath(attachment.uri) + if (!path || !isUploadJailPath(path)) return null + return { + id: attachment.id, + kind: "image", + name: attachment.name, + mime: attachment.mimeType, + size: Math.floor((attachment.data.length * 3) / 4), + path, + uri: null, + } +} diff --git a/src/lib/composer-draft-sync.test.ts b/src/lib/composer-draft-sync.test.ts index 956a7251c..838ba86aa 100644 --- a/src/lib/composer-draft-sync.test.ts +++ b/src/lib/composer-draft-sync.test.ts @@ -4,6 +4,14 @@ import { conversationIdFromDraftKey, shouldApplyRemoteDraft, } from "./composer-draft-sync" +import { + attachmentsEqual, + collectEditorFileAttachments, + draftSnapshotKey, + fileUriToPath, + imageToDraftAttachment, + isUploadJailPath, +} from "./composer-draft-attachments" describe("conversationIdFromDraftKey", () => { it("accepts persisted conversation keys only", () => { @@ -65,3 +73,91 @@ describe("shouldApplyRemoteDraft", () => { ).toBe(false) }) }) + +describe("composer draft attachments", () => { + it("fingerprints text plus refs", () => { + const a = draftSnapshotKey("hi", [ + { id: "1", kind: "file", name: "a.md", uri: "file:///tmp/a.md" }, + ]) + const b = draftSnapshotKey("hi", [ + { id: "1", kind: "file", name: "a.md", uri: "file:///tmp/a.md" }, + ]) + const c = draftSnapshotKey("hi", []) + expect(a).toBe(b) + expect(a).not.toBe(c) + expect(attachmentsEqual([], [])).toBe(true) + }) + + it("decodes file uris and recognizes the uploads jail", () => { + expect(fileUriToPath("file:///C:/Users/a/.codeg/uploads/anon/x.png")).toBe( + "C:/Users/a/.codeg/uploads/anon/x.png" + ) + expect( + isUploadJailPath("C:/Users/a/.codeg/uploads/anon/x.png") + ).toBe(true) + expect(isUploadJailPath("C:/Users/a/Desktop/shot.png")).toBe(false) + }) + + it("only drafts images that already have a jail path", () => { + expect( + imageToDraftAttachment({ + id: "image:1", + type: "image", + data: "abc", + uri: "file:///C:/Users/a/.codeg/uploads/anon/x.png", + name: "x.png", + mimeType: "image/png", + })?.path + ).toContain("uploads") + expect( + imageToDraftAttachment({ + id: "image:2", + type: "image", + data: "abc", + uri: "file:///C:/Users/a/Desktop/x.png", + name: "x.png", + mimeType: "image/png", + }) + ).toBeNull() + }) + + it("collects file:// badges from a composer document", () => { + const files = collectEditorFileAttachments({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "reference", + attrs: { + refType: "file", + label: "notes.md", + uri: "file:///tmp/notes.md", + }, + }, + { + type: "reference", + attrs: { + refType: "file", + label: "embedded", + uri: "codeg://embedded/1", + }, + }, + ], + }, + ], + }) + expect(files).toEqual([ + { + id: "file:file:///tmp/notes.md", + kind: "file", + name: "notes.md", + mime: null, + size: 0, + path: null, + uri: "file:///tmp/notes.md", + }, + ]) + }) +}) diff --git a/src/lib/types.ts b/src/lib/types.ts index 60d35826e..ad2ae54b2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -485,11 +485,22 @@ export interface ComposerDraftChanged { export const COMPOSER_DRAFT_CHANGED_EVENT = "composer-draft://changed" +export interface ComposerDraftAttachment { + id: string + kind: "image" | "file" + name: string + mime?: string | null + size?: number + path?: string | null + uri?: string | null +} + export interface ComposerDraft { conversation_id: number text: string revision: number origin: string + attachments?: ComposerDraftAttachment[] } export interface ComposerDraftPutResult { From 698050f7a2dea1c1e8260f6842dc6eb264f9cfa0 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:03:30 -0700 Subject: [PATCH 3/3] chore: format after rebase onto main --- src/lib/api.ts | 4 +--- src/lib/composer-draft-sync.test.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index bb68a21a6..a837d6e4f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2859,9 +2859,7 @@ export async function putComposerDraft( }) } -export async function readUploadAttachment( - path: string -): Promise<{ +export async function readUploadAttachment(path: string): Promise<{ data: string name: string size: number diff --git a/src/lib/composer-draft-sync.test.ts b/src/lib/composer-draft-sync.test.ts index 838ba86aa..f5316857c 100644 --- a/src/lib/composer-draft-sync.test.ts +++ b/src/lib/composer-draft-sync.test.ts @@ -92,9 +92,7 @@ describe("composer draft attachments", () => { expect(fileUriToPath("file:///C:/Users/a/.codeg/uploads/anon/x.png")).toBe( "C:/Users/a/.codeg/uploads/anon/x.png" ) - expect( - isUploadJailPath("C:/Users/a/.codeg/uploads/anon/x.png") - ).toBe(true) + expect(isUploadJailPath("C:/Users/a/.codeg/uploads/anon/x.png")).toBe(true) expect(isUploadJailPath("C:/Users/a/Desktop/shot.png")).toBe(false) })