From 4d489673c050fc1d1c0dd80885c83781c60086ed 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] 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 {