From 8d52014c2f64df3b5949287e2197dec340f131e6 Mon Sep 17 00:00:00 2001 From: htommr Date: Sun, 18 Oct 2026 22:46:14 +0800 Subject: [PATCH] feat: sync local workspace and editor changes --- .../src/memo_file/media_resource.rs | 13 ++ app/flowix-core/src/memo_file/note_index.rs | 9 +- app/flowix-core/src/memo_file/onboarding.rs | 3 + app/flowix-core/src/memo_file/ops/crud.rs | 8 + .../src/memo_file/storage_repair.rs | 2 + app/flowix-core/src/memo_file/tests.rs | 173 ++++++++-------- app/flowix-core/src/memo_file/workspace.rs | 190 ++++++++++++++---- app/flowix-desktop/src/app/bootstrap.rs | 1 - .../src/commands/agent_workspace.rs | 12 -- .../src/storage_initialization.rs | 48 ++++- .../app/agent-window-effects.test.ts | 14 +- .../features/agent/store/agent-data-store.ts | 146 +++++--------- .../agent/store/agent-workspace-slice.test.ts | 46 +++-- .../thread-card/agent-thread-card.test.ts | 2 +- .../editor/extensions/paste-rules/rules.ts | 4 +- .../editor/extensions/paste-rules/table.ts | 17 +- .../editor/extensions/tab-character.ts | 8 +- .../extensions/table/flowix-table.test.ts | 72 ++++++- .../editor/extensions/table/flowix-table.ts | 32 +++ .../editor/extensions/table/table-plugin.ts | 38 ++-- .../editor/markdown-editor-extensions.ts | 8 +- .../editor/markdown-editor.lists.test.tsx | 132 ++++++++++++ .../lib/types/agent-workspace.test-support.ts | 6 +- app/flowix-web/lib/types/agent-workspace.ts | 22 +- .../platform/tauri/client/general.ts | 1 - docs/AGENT-DATA.md | 6 +- 26 files changed, 688 insertions(+), 325 deletions(-) diff --git a/app/flowix-core/src/memo_file/media_resource.rs b/app/flowix-core/src/memo_file/media_resource.rs index ac2d8b5c4..a1cd54ffd 100644 --- a/app/flowix-core/src/memo_file/media_resource.rs +++ b/app/flowix-core/src/memo_file/media_resource.rs @@ -1102,6 +1102,19 @@ impl MemoFile { mod tests { use super::*; + #[test] + fn media_resource_identity_is_notebook_and_normalized_relative_path() { + assert_eq!( + media_resource_id("nb_one", "photos\\summer.png").unwrap(), + "nb_one::photos/summer.png" + ); + assert_ne!( + media_resource_id("nb_one", "photos/summer.png").unwrap(), + media_resource_id("nb_two", "photos/summer.png").unwrap() + ); + assert!(media_resource_id("nb_one", "../summer.png").is_err()); + } + #[test] fn background_note_preparation_preserves_shared_media_properties() { let (memo, _temp, root) = fixture(); diff --git a/app/flowix-core/src/memo_file/note_index.rs b/app/flowix-core/src/memo_file/note_index.rs index fb16287b2..5df18b3c1 100644 --- a/app/flowix-core/src/memo_file/note_index.rs +++ b/app/flowix-core/src/memo_file/note_index.rs @@ -2146,6 +2146,9 @@ mod tests { INSERT INTO memos VALUES ('legacy-note.md');").unwrap(); drop(conn); + // Legacy projection cleanup is part of startup schema initialization, + // while ordinary reads keep the common connection path lightweight. + store.initialize_note_index_schema("nb_test").unwrap(); let conn = store.open_note_index_connection("nb_test").unwrap(); let note: String = conn.query_row("SELECT relative_path FROM notes", [], |row| row.get(0)).unwrap(); assert_eq!(note, "current.md"); @@ -2470,9 +2473,9 @@ mod tests { .unwrap(); assert_eq!(immediate_path, created.relative_path); drop(conn); - // Creation updates the Note projection immediately; the first full scan records the - // parser version and reparses that row once. - assert_eq!(store.reconcile_note_index("nb_test").unwrap().updated, 1); + // Creation already writes the current parsed projection, so a full scan + // sees it as unchanged. + assert_eq!(store.reconcile_note_index("nb_test").unwrap().unchanged, 1); let conn = store.open_note_index_connection("nb_test").unwrap(); let memo_path: String = conn .query_row("SELECT relative_path FROM memos", [], |row| row.get(0)) diff --git a/app/flowix-core/src/memo_file/onboarding.rs b/app/flowix-core/src/memo_file/onboarding.rs index e02a80bf0..2779f4aec 100644 --- a/app/flowix-core/src/memo_file/onboarding.rs +++ b/app/flowix-core/src/memo_file/onboarding.rs @@ -280,6 +280,9 @@ mod tests { mf.write_index(&MemoIndexFile::default()).unwrap(); fs::remove_file(mf.get_memo_base().join("Welcome.md")).unwrap(); fs::remove_file(mf.get_memo_base().join("欢迎文档.md")).unwrap(); + // The path projection is authoritative for emptiness. Reconcile the + // externally removed files before asking onboarding to seed again. + mf.reconcile_note_index_blocking("nb_default").unwrap(); assert!(mf.seed_onboarding_docs().unwrap()); assert_eq!(mf.list_note_paths_from_disk("nb_default").unwrap().len(), ONBOARDING_DOCS.len()); diff --git a/app/flowix-core/src/memo_file/ops/crud.rs b/app/flowix-core/src/memo_file/ops/crud.rs index 63eea49cd..7fbaf2676 100644 --- a/app/flowix-core/src/memo_file/ops/crud.rs +++ b/app/flowix-core/src/memo_file/ops/crud.rs @@ -480,11 +480,19 @@ impl MemoFile { let new_content = existing; atomic_write_bytes(&new_path, new_content.as_bytes())?; + let old_relative_path = memo.relative_path.clone(); let mut updated = memo; updated.filename = new_filename; updated.relative_path = new_relative_path; updated.updated_at = chrono::Utc::now().timestamp_millis(); apply_derived_memo_fields(&mut updated, &new_content); + if updated.relative_path != old_relative_path { + MemoFile::sync_index_on_delete_for_notebook_id_locked( + self, + ¬ebook_id, + &old_relative_path, + )?; + } MemoFile::sync_index_on_write_for_notebook_id_locked(self, ¬ebook_id, &updated)?; Ok(updated) } diff --git a/app/flowix-core/src/memo_file/storage_repair.rs b/app/flowix-core/src/memo_file/storage_repair.rs index 421aeb62c..9101d144b 100644 --- a/app/flowix-core/src/memo_file/storage_repair.rs +++ b/app/flowix-core/src/memo_file/storage_repair.rs @@ -313,6 +313,8 @@ mod storage_rebuild_tests { updated_at: 0, }]) .unwrap(); + // Database repair runs during schema initialization before indexing. + memo.initialize_note_index_schema("nb_kept").unwrap(); memo.reconcile_note_index_blocking("nb_kept").unwrap(); assert_eq!( fs::read(root.join("kept.md")).unwrap(), diff --git a/app/flowix-core/src/memo_file/tests.rs b/app/flowix-core/src/memo_file/tests.rs index 8191e3ad9..831adb6e3 100644 --- a/app/flowix-core/src/memo_file/tests.rs +++ b/app/flowix-core/src/memo_file/tests.rs @@ -66,7 +66,8 @@ fn create_memo_uses_relative_path_as_compatibility_key() { let (mf, _tmp) = fresh_memo_file(); let memo = mf.create_memo("Eight", "body", None).unwrap(); - assert_eq!(memo.relative_path, memo.relative_path); + assert_eq!(memo.relative_path, "Eight.md"); + assert_eq!(mf.read_memo(&memo.relative_path).unwrap().relative_path, memo.relative_path); let content = fs::read_to_string(mf.get_memo_base().join(&memo.filename)).unwrap(); assert!(!content.contains("flowix_key:")); @@ -193,83 +194,45 @@ fn pending_data_migrations_skip_unavailable_notebooks() { } #[test] -fn relative_path_migration_recovers_populated_replacement_table() { +fn legacy_memo_index_retirement_rebuilds_path_projection_from_markdown() { let (mf, _tmp) = fresh_memo_file(); let memo = mf.create_memo("Recovered", "body", None).unwrap(); + let note_path = mf.get_memo_base().join(&memo.relative_path); + let original_content = fs::read(¬e_path).unwrap(); - // Legacy memo tables (memos / memo_index_state / schema_migrations) live - // in the per-notebook notebook.db, not the registry. + // Startup retirement discards the old Memo-ID projection after rebuilding + // the path projection from the Markdown source of truth. let notebook_db = mf.notebook_db_path("nb_test").unwrap(); - let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let state_before: i64 = conn - .query_row( - "SELECT last_updated FROM memo_index_state WHERE notebook_id = 'nb_test'", - [], - |row| row.get(0), - ) - .unwrap(); - conn.execute_batch( - r#" - PRAGMA foreign_keys = OFF; - DELETE FROM schema_migrations WHERE migration_key = 'memo_relative_paths_v1'; - CREATE TABLE memos_relative_paths_v1 ( - id TEXT PRIMARY KEY, - notebook_id TEXT NOT NULL, - filename TEXT NOT NULL, - relative_path TEXT NOT NULL, - preview TEXT NOT NULL, - thumbnail TEXT, - thumbnail_checked INTEGER NOT NULL DEFAULT 0, - agents_checked INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - favorited INTEGER NOT NULL, - icon TEXT, - properties TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY(notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE, - UNIQUE(notebook_id, relative_path) - ); - INSERT INTO memos_relative_paths_v1 SELECT * FROM memos; - DELETE FROM memos; - PRAGMA foreign_keys = ON; - "#, - ) - .unwrap(); - drop(conn); - - mf.invalidate_caches(); - let recovered = mf.read_index().expect("half-migrated index should recover"); - assert_eq!(recovered.memos.len(), 1); - assert_eq!(recovered.memos[0].relative_path, memo.relative_path); + mf.retire_legacy_memo_index("nb_test").unwrap(); + assert_eq!(fs::read(¬e_path).unwrap(), original_content); + assert_eq!(mf.read_note_entries("nb_test").unwrap().len(), 1); + assert_eq!(mf.read_note_entry_by_path("nb_test", &memo.relative_path).unwrap().unwrap().relative_path, memo.relative_path); let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let replacement_exists: i64 = conn + let legacy_memos_exists: i64 = conn .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memos_relative_paths_v1')", + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memos')", [], |row| row.get(0), ) .unwrap(); - assert_eq!(replacement_exists, 0); - let marker_exists: i64 = conn + assert_eq!(legacy_memos_exists, 0); + let legacy_migration_tables: i64 = conn .query_row( - "SELECT COUNT(*) FROM schema_migrations WHERE migration_key = 'memo_relative_paths_v1'", + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('schema_migrations', 'memo_index_state', 'memo_lifecycles')", [], |row| row.get(0), ) .unwrap(); - assert_eq!(marker_exists, 1); - let state_after: i64 = conn + assert_eq!(legacy_migration_tables, 0); + let retired_marker: String = conn .query_row( - "SELECT last_updated FROM memo_index_state WHERE notebook_id = 'nb_test'", + "SELECT value FROM note_index_meta WHERE key='legacy_memo_index_retired'", [], |row| row.get(0), ) .unwrap(); - assert!( - state_after > state_before, - "recovery must invalidate caches" - ); + assert_eq!(retired_marker, "1"); } #[test] @@ -473,9 +436,10 @@ fn read_memo_for_explicit_notebook_resolves_notebook_location() { } #[test] -fn write_memo_by_id_updates_global_notebook_without_switching_current() { +fn write_memo_global_is_scoped_by_notebook_and_relative_path() { let (mut mf, tmp) = fresh_memo_file(); let current_memo = mf.create_memo("Current", "# Current", None).unwrap(); + let current_collision = mf.create_memo("Other", "# Current copy", None).unwrap(); let other_dir = tmp.join("other-global-write"); fs::create_dir_all(&other_dir).unwrap(); @@ -495,29 +459,36 @@ fn write_memo_by_id_updates_global_notebook_without_switching_current() { mf.set_current_notebook(Some("nb_other".to_string())); let other_memo = mf.create_memo("Other", "# Other", None).unwrap(); - mf.set_current_notebook(Some("nb_test".to_string())); + assert_eq!(current_collision.relative_path, other_memo.relative_path); let updated = mf .write_memo_renaming_on_title_change_global(&other_memo.relative_path, "Renamed Other\nBody") .expect("global write ok"); - let other_list = mf - .read_index_for_notebook_id(Some("nb_other")) - .expect("read other index") - .expect("other index exists"); - let current_list = mf.read_index().expect("current index exists"); - let final_content = fs::read_to_string(other_dir.join(&updated.filename)).unwrap(); + let other_content = fs::read_to_string(other_dir.join(&updated.filename)).unwrap(); + let current_content = fs::read_to_string(tmp.join(¤t_collision.filename)).unwrap(); - assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_test")); - assert_eq!(updated.relative_path, other_memo.relative_path); + assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_other")); + assert_eq!(updated.relative_path, "Renamed Other.md"); assert_eq!(updated.filename, "Renamed Other.md"); - assert!(final_content.contains("Renamed Other")); - assert!(other_list.memos.iter().any(|memo| memo.relative_path == other_memo.relative_path)); - assert_eq!(current_list.memos.len(), 1); - assert_eq!(current_list.memos[0].relative_path, current_memo.relative_path); + assert!(!other_dir.join(&other_memo.filename).exists()); + assert!(other_content.contains("Renamed Other")); + assert!(current_content.contains("# Current copy")); + assert!(mf.read_memo_for_notebook_id("nb_test", ¤t_collision.relative_path).is_some()); + assert!(mf.read_memo_for_notebook_id("nb_other", &updated.relative_path).is_some()); + assert!(mf.read_memo_for_notebook_id("nb_other", &other_memo.relative_path).is_none()); + assert!(mf.read_memo_for_notebook_id("nb_test", ¤t_memo.relative_path).is_some()); + assert!(mf + .read_indexed_note_entry_by_path("nb_other", &other_memo.relative_path) + .unwrap() + .is_none()); + assert!(mf + .read_indexed_note_entry_by_path("nb_other", &updated.relative_path) + .unwrap() + .is_some()); } #[test] -fn write_memo_global_ignores_generated_conflict_suffix() { +fn write_memo_global_ignores_generated_conflict_suffix_within_selected_notebook() { let (mut mf, tmp) = fresh_memo_file(); let other_dir = tmp.join("other-global-suffix"); fs::create_dir_all(&other_dir).unwrap(); @@ -535,6 +506,15 @@ fn write_memo_global_ignores_generated_conflict_suffix() { mf.write_notebook_configs(&configs) .expect("write notebooks"); + mf.set_current_notebook(Some("nb_test".to_string())); + let _current_existing = mf + .create_memo("Stable", "Stable\ncurrent first\n", None) + .unwrap(); + let current_memo = mf + .create_memo("Stable", "Stable\ncurrent target\n", None) + .unwrap(); + assert_eq!(current_memo.relative_path, "Stable-1.md"); + mf.set_current_notebook(Some("nb_other".to_string())); let _existing = mf .create_memo("Stable", "Stable\nexisting\n", None) @@ -548,8 +528,16 @@ fn write_memo_global_ignores_generated_conflict_suffix() { let updated = mf .write_memo_renaming_on_title_change_global(&memo.relative_path, "Stable\nnew body\n") .expect("global write ok"); + let current_after = mf + .read_memo_for_notebook_id("nb_test", ¤t_memo.relative_path) + .unwrap(); assert_eq!(updated.filename, "Stable-1.md"); + assert_eq!(current_after.relative_path, current_memo.relative_path); + let current_content = fs::read_to_string(tmp.join("Stable-1.md")).unwrap(); + let other_content = fs::read_to_string(other_dir.join("Stable-1.md")).unwrap(); + assert!(current_content.contains("new body")); + assert!(other_content.contains("old body")); assert!(other_dir.join("Stable-1.md").exists()); assert!(!other_dir.join("Stable-2.md").exists()); assert_eq!(mf.current_notebook_id_value().as_deref(), Some("nb_test")); @@ -895,7 +883,8 @@ fn rename_memo_renames_file_and_updates_list() { assert_eq!(updated.filename, "New.md"); assert!(!base.join("Old.md").exists(), "old file should be gone"); assert!(base.join("New.md").exists()); - let queried = mf.read_memo(&memo.relative_path).expect("still in list"); + assert!(mf.read_memo(&memo.relative_path).is_none(), "renaming changes the path identity"); + let queried = mf.read_memo(&updated.relative_path).expect("new path is indexed"); assert_eq!(queried.filename, "New.md"); } @@ -1008,7 +997,8 @@ fn write_rename_changes_disk_when_first_line_changes() { assert_eq!(updated.filename, "Renamed.md"); assert!(!base.join("Old.md").exists(), "old file should be gone"); assert!(base.join("Renamed.md").exists()); - let queried = mf.read_memo(&memo.relative_path).expect("still in list"); + assert!(mf.read_memo(&memo.relative_path).is_none(), "renaming changes the path identity"); + let queried = mf.read_memo(&updated.relative_path).expect("new path is indexed"); assert_eq!(queried.filename, "Renamed.md"); } @@ -1024,7 +1014,7 @@ fn write_rename_noop_when_first_line_unchanged() { .write_memo_renaming_on_title_change(&memo.relative_path, new_body) .expect("write ok"); assert!(base.join("Stable.md").exists()); - let queried = mf.read_memo(&memo.relative_path).expect("still in list"); + let queried = mf.read_memo(&memo.relative_path).expect("same path remains indexed"); assert_eq!(queried.filename, "Stable.md"); } @@ -1144,7 +1134,8 @@ fn write_rename_empty_body_uses_untitled_memo() { assert_eq!(updated.filename, "Untitled Memo.md"); assert!(!base.join("Keep.md").exists()); assert!(base.join("Untitled Memo.md").exists()); - let queried = mf.read_memo(&memo.relative_path).expect("still in list"); + assert!(mf.read_memo(&memo.relative_path).is_none(), "renaming changes the path identity"); + let queried = mf.read_memo(&updated.relative_path).expect("new path is indexed"); assert_eq!(queried.filename, "Untitled Memo.md"); assert_eq!(queried.preview, ""); } @@ -1886,11 +1877,7 @@ fn independent_instances_create_without_lost_rows_or_file_overwrites() { use std::thread; let (first, base) = fresh_memo_file(); - let config_dir = first - .registry_db_path() - .parent() - .expect("config dir") - .to_path_buf(); + let config_dir = base.join("config"); let mut second = MemoFile::new(config_dir.clone()); second.set_current_notebook(Some("nb_test".to_string())); @@ -1938,12 +1925,8 @@ fn independent_instances_create_without_lost_rows_or_file_overwrites() { #[test] fn independent_instance_writes_are_visible_after_local_cache_was_populated() { - let (first, _base) = fresh_memo_file(); - let config_dir = first - .registry_db_path() - .parent() - .expect("config dir") - .to_path_buf(); + let (first, base) = fresh_memo_file(); + let config_dir = base.join("config"); assert!(first.read_index().is_none()); let mut second = MemoFile::new(config_dir); @@ -2428,6 +2411,11 @@ fn read_body(mf: &MemoFile, filename: &str) -> String { fs::read_to_string(path).unwrap() } +fn prepare_tag_index(mf: &MemoFile) { + mf.reconcile_note_index_blocking("nb_test") + .expect("tag tests require a ready note index"); +} + #[test] fn move_tag_rewrites_exact_match_in_both_sources() { let (mf, _base) = fresh_memo_file(); @@ -2440,6 +2428,7 @@ fn move_tag_rewrites_exact_match_in_both_sources() { ) .unwrap(); let original_body = read_body(&mf, &relative_path); + prepare_tag_index(&mf); let report = mf .move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") @@ -2479,6 +2468,7 @@ fn move_tag_rewrites_subtree_in_both_sources() { ) .unwrap(); + prepare_tag_index(&mf); let report = mf .move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") .unwrap(); @@ -2513,6 +2503,7 @@ fn move_tag_leaves_unrelated_tags_intact() { ) .unwrap(); + prepare_tag_index(&mf); mf.move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") .unwrap(); @@ -2539,6 +2530,7 @@ fn move_tag_rewrites_body_only_membership_but_not_code() { ) .unwrap(); + prepare_tag_index(&mf); let report = mf .move_memo_tag_locked(Some("nb_test"), "old/path", "new/path") .unwrap(); @@ -2564,6 +2556,7 @@ fn delete_tag_removes_body_only_membership_but_not_code() { ) .unwrap(); + prepare_tag_index(&mf); let report = mf .delete_memo_tag_locked(Some("nb_test"), "remove/path") .unwrap(); @@ -2586,6 +2579,7 @@ fn delete_tag_ignores_unrelated_invalid_legacy_frontmatter_path() { let content = format!("---\ntags:\n - \"legacy tag\"\n---\n{content}"); std::fs::write(&path, content).unwrap(); + prepare_tag_index(&mf); let report = mf.delete_memo_tag_locked(Some("nb_test"), "1").unwrap(); assert_eq!(report.affected_memos, 1); @@ -2622,6 +2616,7 @@ fn move_tag_rejects_target_conflict() { .create_note_by_path("nb_test", None, "B", "---\ntags: [中国/曼谷]\n---\n正文") .unwrap(); + prepare_tag_index(&mf); // 已有 "中国/曼谷" → 移动 "旅行/曼谷" → "中国/曼谷" 冲突 let err = mf .move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") @@ -2661,6 +2656,7 @@ fn move_tag_handles_multiple_memos() { .create_note_by_path("nb_test", None, "C", "---\ntags: [不相关]\n---\n正文") .unwrap(); // 不应被影响 + prepare_tag_index(&mf); let report = mf .move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") .unwrap(); @@ -2691,6 +2687,7 @@ fn move_tag_updates_body_without_frontmatter_identity() { ) .unwrap(); + prepare_tag_index(&mf); mf.move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") .unwrap(); @@ -2705,6 +2702,7 @@ fn move_tag_no_match_returns_zero_affected() { .create_note_by_path("nb_test", None, "Empty", "正文 no tags") .unwrap(); + prepare_tag_index(&mf); let report = mf .move_memo_tag_locked(Some("nb_test"), "旅行/曼谷", "中国/曼谷") .unwrap(); @@ -2841,6 +2839,7 @@ fn tag_prefix_counts_use_distinct_memos_not_tag_sum() { #[test] fn tag_prefix_counts_empty_notebook_returns_empty_map() { let (mf, _base) = fresh_memo_file(); + prepare_tag_index(&mf); let counts = mf .read_tag_prefix_counts_for_notebook_id(Some("nb_test")) .unwrap(); diff --git a/app/flowix-core/src/memo_file/workspace.rs b/app/flowix-core/src/memo_file/workspace.rs index 43857a87c..8b0ee22e3 100644 --- a/app/flowix-core/src/memo_file/workspace.rs +++ b/app/flowix-core/src/memo_file/workspace.rs @@ -53,14 +53,103 @@ impl MemoFile { Ok(conn) } - /// This is the only remaining reader of old global Agent runtime defaults. - pub fn read_legacy_agent_runtime(&self) -> std::io::Result> { - Ok(self - .read_legacy_agent_access()? + /// Import legacy notebook folder defaults into the authoritative SQLite + /// workspace tables. The caller records this as a one-time startup job. + /// `_global` applies to notebooks that already exist when the job runs; + /// notebooks created later do not inherit stale global defaults. + pub fn migrate_legacy_agent_workspace(&self) -> std::io::Result { + let legacy = self.read_legacy_agent_access()?; + let Some(files) = legacy .get("defaults") - .and_then(|d| d.get("runtime")) - .cloned()) + .and_then(|defaults| defaults.get("files")) + .and_then(serde_json::Value::as_object) + else { + return Ok(0); + }; + + let notebooks = self.read_notebook_configs()?; + if notebooks.is_empty() { + return Ok(0); + } + if let Some(notebook) = notebooks + .iter() + .find(|notebook| !Path::new(¬ebook.path).is_dir()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("notebook {} is unavailable", notebook.id), + )); + } + + let folders_for = |key: &str| -> Vec<&str> { + files + .get(key) + .and_then(|config| config.get("folders")) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .collect() + }; + let global_folders = folders_for("_global"); + let mut conn = self.workspace_connection()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + let now = chrono::Utc::now().timestamp_millis(); + let mut imported = 0usize; + + for notebook in ¬ebooks { + let mut notebook_imported = 0usize; + for path in folders_for(¬ebook.id) + .into_iter() + .chain(global_folders.iter().copied()) + { + if validate_add_dir(¬ebook.path, path, false).is_err() { + tracing::warn!(notebook_id = %notebook.id, path, "skipping invalid legacy Agent folder default"); + continue; + } + let normalized = normalized_directory(path); + let stored_path = normalized.to_string_lossy(); + let path_key = directory_key(path); + let label = normalized + .file_name() + .unwrap_or_default() + .to_string_lossy(); + notebook_imported += tx + .execute( + "INSERT OR IGNORE INTO notebook_add_dirs \ + (id,notebook_id,path,path_key,label,enabled,sort,created_at,updated_at) \ + VALUES (?1,?2,?3,?4,?5,1,\ + COALESCE((SELECT MAX(sort)+1 FROM notebook_add_dirs WHERE notebook_id=?2),0),?6,?6)", + params![ + uuid::Uuid::new_v4().to_string(), + notebook.id, + stored_path, + path_key, + label, + now + ], + ) + .map_err(sqlite_to_io)?; + } + if notebook_imported > 0 { + tx.execute( + "INSERT INTO notebook_workspace_state VALUES (?1,1) \ + ON CONFLICT(notebook_id) DO UPDATE SET revision=revision+1", + [¬ebook.id], + ) + .map_err(sqlite_to_io)?; + imported += notebook_imported; + } + } + + tx.commit().map_err(sqlite_to_io)?; + Ok(imported) } + fn read_legacy_agent_access(&self) -> std::io::Result { let path = self.registry.config_dir.join("agent-access.json"); match fs::read(&path) { @@ -235,7 +324,7 @@ mod tests { (temp, memo, notebook) } #[test] - fn imports_local_mounts_once_and_does_not_grant_orphan_global_entries() { + fn workspace_mounts_are_read_from_sqlite_not_legacy_json() { let (temp, memo, notebook) = fixture(); let repo = temp.path().join("repo"); fs::create_dir(&repo).unwrap(); @@ -255,9 +344,13 @@ mod tests { r#"{"entries":[{"kind":"folder","path":"/orphan","enabled":true}]}"#, ) .unwrap(); - let first = memo.read_notebook_workspace().unwrap(); - assert_eq!(first.notebook_configs["one"].add_dirs.len(), 2); - assert!(first.notebook_configs["one"].add_dirs[1].missing); + let initial = memo.read_notebook_workspace().unwrap(); + assert!(initial.notebook_configs["one"].add_dirs.is_empty()); + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 0); + assert!(memo.attached_directory_paths().unwrap().is_empty()); + + memo.update_notebook_add_dir("one", repo.to_str().unwrap(), true) + .unwrap(); assert_eq!( memo.attached_directory_paths().unwrap(), vec![dunce::canonicalize(&repo).unwrap()] @@ -269,7 +362,7 @@ mod tests { memo.read_notebook_workspace().unwrap().notebook_configs["one"] .add_dirs .len(), - 1 + 0 ); assert!(memo.attached_directory_paths().unwrap().is_empty()); } @@ -310,7 +403,7 @@ mod tests { ); } #[test] - fn imports_global_legacy_defaults_and_defers_unavailable_notebooks() { + fn legacy_notebook_file_defaults_are_imported_once_for_existing_notebooks() { let (temp, memo, notebook) = fixture(); let repo = temp.path().join("repo"); fs::create_dir(&repo).unwrap(); @@ -319,32 +412,62 @@ mod tests { serde_json::json!({"defaults":{"files":{"one":{"folders":[repo]}}}}).to_string(), ) .unwrap(); - fs::rename(¬ebook.path, temp.path().join("parked")).unwrap(); - assert!( - memo.read_notebook_workspace().unwrap().notebook_configs["one"] - .add_dirs - .is_empty() - ); - fs::rename(temp.path().join("parked"), ¬ebook.path).unwrap(); + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 1); + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 0); + let imported = memo.read_notebook_workspace().unwrap(); + assert_eq!(imported.notebook_configs["one"].add_dirs.len(), 1); assert_eq!( - memo.read_notebook_workspace().unwrap().notebook_configs["one"] - .add_dirs - .len(), - 1 + imported.notebook_configs["one"].add_dirs[0].path, + dunce::canonicalize(&repo).unwrap().to_string_lossy() ); + let mut new_notebook = notebook.clone(); + new_notebook.id = "two".into(); + new_notebook.name = "Two".into(); + new_notebook.path = temp.path().join("two").to_string_lossy().into(); + fs::create_dir(&new_notebook.path).unwrap(); + memo.write_notebook_configs(&[notebook, new_notebook]).unwrap(); + let after_new_notebook = memo.read_notebook_workspace().unwrap(); + assert!(after_new_notebook.notebook_configs["two"].add_dirs.is_empty()); assert!(memo .update_notebook_add_dir("one", temp.path().to_str().unwrap(), true) .is_err()); } + #[test] - fn invalid_legacy_json_remains_retryable() { + fn legacy_workspace_migration_defers_until_registered_notebooks_are_available() { let (temp, memo, notebook) = fixture(); - let source = Path::new(¬ebook.path).join(".flowix/agent.json"); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + serde_json::json!({"defaults":{"files":{"one":{"folders":[repo]}}}}).to_string(), + ) + .unwrap(); + + let parked = temp.path().join("parked-notebook"); + fs::rename(¬ebook.path, &parked).unwrap(); + let error = memo.migrate_legacy_agent_workspace().unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + assert!(memo.attached_directory_paths().unwrap().is_empty()); + + fs::rename(&parked, ¬ebook.path).unwrap(); + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 1); + assert_eq!(memo.read_notebook_workspace().unwrap().notebook_configs["one"].add_dirs.len(), 1); + } + + #[test] + fn invalid_legacy_agent_access_is_backed_up_without_workspace_defaults() { + let (temp, memo, _notebook) = fixture(); + let source = temp.path().join("config/agent-access.json"); fs::write(&source, "broken").unwrap(); - assert!(memo.read_notebook_workspace().is_err()); - fs::write(&source, r#"{"version":1,"addDirs":[]}"#).unwrap(); + + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 0); + assert!(!source.exists()); + let backup_dir = temp.path().join("config/rebuild-backups"); + let backups = fs::read_dir(backup_dir).unwrap().collect::, _>>().unwrap(); + assert_eq!(backups.len(), 1); + assert!(backups[0].path().join("agent-access.json").is_file()); assert!(memo.read_notebook_workspace().is_ok()); - assert!(temp.path().join("config/db/local.db").is_file()); } } @@ -409,7 +532,7 @@ mod concurrency_tests { mod legacy_global_defaults_tests { use super::*; #[test] - fn stale_global_defaults_apply_only_to_preupgrade_notebooks() { + fn legacy_global_defaults_apply_to_notebooks_present_during_migration_only() { let temp = tempfile::tempdir().unwrap(); let notes = temp.path().join("old"); fs::create_dir(¬es).unwrap(); @@ -430,12 +553,9 @@ mod legacy_global_defaults_tests { memo.write_notebook_configs(std::slice::from_ref(&old)) .unwrap(); fs::write(config.join("agent-access.json"),serde_json::json!({"entries":[{"id":"old","kind":"notebook"}],"defaults":{"files":{"_global":{"folders":[repo]}}}}).to_string()).unwrap(); - assert_eq!( - memo.read_notebook_workspace().unwrap().notebook_configs["old"] - .add_dirs - .len(), - 1 - ); + assert_eq!(memo.migrate_legacy_agent_workspace().unwrap(), 1); + assert_eq!(memo.read_notebook_workspace().unwrap().notebook_configs["old"] + .add_dirs.len(), 1); let mut new = old.clone(); new.id = "new".into(); new.path = temp.path().join("new").to_string_lossy().into(); diff --git a/app/flowix-desktop/src/app/bootstrap.rs b/app/flowix-desktop/src/app/bootstrap.rs index 951687be1..511f8e471 100644 --- a/app/flowix-desktop/src/app/bootstrap.rs +++ b/app/flowix-desktop/src/app/bootstrap.rs @@ -556,7 +556,6 @@ pub fn run() { commands::cloud::cloud_restore_note_revision, // Device-local notebook workspaces (local.db). commands::agent_workspace::get_agent_workspace, - commands::agent_workspace::get_legacy_agent_preferences, commands::agent_workspace::attach_notebook_directory, commands::agent_workspace::detach_notebook_directory, // System metadata (JSON, ~/.flowix/boot/system.json) diff --git a/app/flowix-desktop/src/commands/agent_workspace.rs b/app/flowix-desktop/src/commands/agent_workspace.rs index 61c4edd1c..b8c1cc119 100644 --- a/app/flowix-desktop/src/commands/agent_workspace.rs +++ b/app/flowix-desktop/src/commands/agent_workspace.rs @@ -15,18 +15,6 @@ pub fn get_agent_workspace(state: State) -> Result, -) -> Result, String> { - state - .memo_file - .read() - .map_err(|_| "memo file lock poisoned".to_string())? - .read_legacy_agent_runtime() - .map_err(|e| e.to_string()) -} - #[tauri::command] pub fn attach_notebook_directory( notebook_id: String, diff --git a/app/flowix-desktop/src/storage_initialization.rs b/app/flowix-desktop/src/storage_initialization.rs index 680e9f6b3..95788608e 100644 --- a/app/flowix-desktop/src/storage_initialization.rs +++ b/app/flowix-desktop/src/storage_initialization.rs @@ -18,6 +18,7 @@ pub const CONFIG_LOCAL_MIGRATIONS_JOB: &str = "config_local_migrations_v1"; #[derive(Clone, Copy)] enum GlobalInitializationJob { + AgentWorkspace, AgentLayout, LocalConfiguration, WatcherSettings, @@ -25,7 +26,8 @@ enum GlobalInitializationJob { } impl GlobalInitializationJob { - const ALL: [Self; 4] = [ + const ALL: [Self; 5] = [ + Self::AgentWorkspace, Self::AgentLayout, Self::LocalConfiguration, Self::WatcherSettings, @@ -34,6 +36,7 @@ impl GlobalInitializationJob { fn key(self) -> &'static str { match self { + Self::AgentWorkspace => "agent_workspace_v1", Self::AgentLayout => "config_dsh_layout_v2", Self::LocalConfiguration => CONFIG_LOCAL_MIGRATIONS_JOB, Self::WatcherSettings => "config_watcher_exclusions_v1", @@ -44,16 +47,23 @@ impl GlobalInitializationJob { fn is_optional(self) -> bool { matches!( self, - Self::AgentLayout | Self::WatcherSettings | Self::Templates + Self::AgentWorkspace | Self::AgentLayout | Self::WatcherSettings | Self::Templates ) } fn run( self, + memo_file: &MemoFile, user_config: &crate::config::UserConfigStore, user_config_dir: &Path, ) -> Result<(), String> { match self { + Self::AgentWorkspace => memo_file + .migrate_legacy_agent_workspace() + .map(|imported| { + tracing::info!(imported, "legacy Agent workspace defaults imported"); + }) + .map_err(|error| format!("import legacy Agent workspace defaults failed: {error}")), Self::AgentLayout => user_config.initialize_legacy_dsh_layout(), Self::LocalConfiguration => user_config.initialize_legacy_config_migrations(), Self::WatcherSettings => user_config @@ -144,7 +154,7 @@ pub fn initialize_legacy_storage_with_progress( let result = run_job_with_progress( memo_file, job.key(), - || job.run(user_config, user_config_dir), + || job.run(memo_file, user_config, user_config_dir), &mut report, &mut completed, total, @@ -570,6 +580,38 @@ mod tests { } } + #[test] + fn startup_imports_legacy_agent_workspace_defaults_once() { + let (temp, memo, notebook) = fixture(); + let repo = temp.path().join("repo"); + fs::create_dir_all(&repo).unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + serde_json::json!({"defaults":{"files":{"_global":{"folders":[repo]}}}}) + .to_string(), + ) + .unwrap(); + let config = temp.path().join("config"); + let app_data = temp.path().join("app-data"); + let user = crate::config::UserConfigStore::new(temp.path().to_path_buf()); + let device = DeviceRegistry::load(&config, "test"); + + initialize_legacy_storage(&device, &memo, &user, &config, &app_data).unwrap(); + assert!(memo.local_migration_completed("agent_workspace_v1").unwrap()); + assert_eq!(memo.read_notebook_workspace().unwrap().notebook_configs["test"].add_dirs.len(), 1); + + let mut new_notebook = notebook; + new_notebook.id = "later".into(); + new_notebook.name = "later".into(); + new_notebook.path = temp.path().join("later").to_string_lossy().into(); + fs::create_dir_all(&new_notebook.path).unwrap(); + memo.write_notebook_configs(&[memo.read_notebook_configs().unwrap()[0].clone(), new_notebook]) + .unwrap(); + + initialize_legacy_storage(&device, &memo, &user, &config, &app_data).unwrap(); + assert!(memo.read_notebook_workspace().unwrap().notebook_configs["later"].add_dirs.is_empty()); + } + #[test] fn current_view_preferences_do_not_require_reset() { let (_temp, memo, notebook) = fixture(); diff --git a/app/flowix-web/app/agent-window-effects.test.ts b/app/flowix-web/app/agent-window-effects.test.ts index bbf39e667..c5d37930d 100644 --- a/app/flowix-web/app/agent-window-effects.test.ts +++ b/app/flowix-web/app/agent-window-effects.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ refreshAgentModelCatalog: vi.fn(async () => ({ items: [] })), loadWorkspace: vi.fn(async (): Promise => undefined), initializePiMode: vi.fn(async () => undefined), - initializeLegacyPreferences: vi.fn(async () => undefined), + initializeLegacySessionSettings: vi.fn(async () => undefined), listenWorkspace: vi.fn(), unlistenWorkspace: vi.fn(), invalidateNotebookCache: vi.fn(), @@ -39,10 +39,10 @@ vi.mock('@features/agent/store/agent-runtime-store', () => ({ })); vi.mock('@features/agent/store/agent-data-store', () => ({ ensureAgentPreferencesReady: async () => { - await Promise.all([mocks.initializePiMode(), mocks.initializeLegacyPreferences()]); + await Promise.all([mocks.initializePiMode(), mocks.initializeLegacySessionSettings()]); }, - useAgentDataStore: (selector: (state: { initializePiMode: typeof mocks.initializePiMode; initializeLegacyPreferences: typeof mocks.initializeLegacyPreferences }) => unknown) => ( - selector({ initializePiMode: mocks.initializePiMode, initializeLegacyPreferences: mocks.initializeLegacyPreferences }) + useAgentDataStore: (selector: (state: { initializePiMode: typeof mocks.initializePiMode; initializeLegacySessionSettings: typeof mocks.initializeLegacySessionSettings }) => unknown) => ( + selector({ initializePiMode: mocks.initializePiMode, initializeLegacySessionSettings: mocks.initializeLegacySessionSettings }) ), })); vi.mock('@features/agent/store/agent-workspace-store', () => ({ @@ -100,7 +100,7 @@ describe('AgentWindowEffects', () => { expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); expect(mocks.initializePiMode).toHaveBeenCalledOnce(); - expect(mocks.initializeLegacyPreferences).toHaveBeenCalledOnce(); + expect(mocks.initializeLegacySessionSettings).toHaveBeenCalledOnce(); expect(mocks.listenWorkspace).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).not.toHaveBeenCalled(); expect(mocks.useAgentEvents).not.toHaveBeenCalled(); @@ -179,7 +179,7 @@ describe('AgentWindowEffects', () => { await act(async () => mocks.listenWorkspace.mock.calls[0][0]()); expect(mocks.loadWorkspace).not.toHaveBeenCalled(); expect(mocks.initializePiMode).not.toHaveBeenCalled(); - expect(mocks.initializeLegacyPreferences).not.toHaveBeenCalled(); + expect(mocks.initializeLegacySessionSettings).not.toHaveBeenCalled(); expect(mocks.useAgentEvents).toHaveBeenCalledOnce(); expect(mocks.acquireMemoEventBridge).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).not.toHaveBeenCalled(); @@ -195,7 +195,7 @@ describe('AgentWindowEffects', () => { await act(async () => admitStartupStage3()); expect(mocks.loadWorkspace).toHaveBeenCalledOnce(); expect(mocks.initializePiMode).toHaveBeenCalledOnce(); - expect(mocks.initializeLegacyPreferences).toHaveBeenCalledOnce(); + expect(mocks.initializeLegacySessionSettings).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).toHaveBeenCalledWith({ force: true }); expect(mocks.prewarmNotebookCache).toHaveBeenCalledOnce(); expect(mocks.waitForWorkspaceRestoration).toHaveBeenCalledOnce(); diff --git a/app/flowix-web/features/agent/store/agent-data-store.ts b/app/flowix-web/features/agent/store/agent-data-store.ts index 0e18a8580..f644c4163 100644 --- a/app/flowix-web/features/agent/store/agent-data-store.ts +++ b/app/flowix-web/features/agent/store/agent-data-store.ts @@ -2,17 +2,13 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createLogger } from "@/lib/logger"; import { STORAGE_KEYS } from "@/lib/constants"; -import { agentWorkspace, piModels } from "@platform/tauri/client"; +import { piModels } from "@platform/tauri/client"; import type { AgentCodexReasoningEffort, AgentCodexServiceTier, AgentPermissionMode, AgentTypeKey, } from "@/types/agent"; -import type { - LegacyAgentRuntimePreference, - LegacyAgentRuntimePreferences, -} from "@/lib/types/agent-workspace"; import { getAgentAccessOptions, getAgentModeOptions, @@ -126,7 +122,7 @@ interface AgentDataLoadState { } export interface AgentDataStoreState { - /** One-time import of legacy runtime/session defaults. */ + /** One-time import of legacy Codex session settings. */ legacyPreferencesMigrated: boolean; /** Per-field revisions make user choices mergeable across app windows. */ userPreferenceRevisions: Partial>>; @@ -135,7 +131,7 @@ export interface AgentDataStoreState { agents: Record; loadState: AgentDataLoadState; initializePiMode: () => Promise; - initializeLegacyPreferences: () => Promise; + initializeLegacySessionSettings: () => Promise; setModelCatalog: (agentType: AgentTypeKey, catalog: AgentModelCatalog) => void; invalidateModelCatalog: (agentType: AgentTypeKey) => void; invalidateSkillCatalog: (agentType: AgentTypeKey, notebookId: string) => void; @@ -164,14 +160,11 @@ export interface AgentDataStoreState { model: { id: string; providerId?: string } | undefined, effort: AgentCodexReasoningEffort, ) => void; - migrateLegacyPreferences: ( - runtime: LegacyAgentRuntimePreferences | undefined, - codexSessionSettings?: { - agentCodexModel: string; - agentPermissionMode: AgentPermissionMode; - agentCodexReasoningEffort: AgentCodexReasoningEffort; - }, - ) => void; + migrateLegacySessionSettings: (codexSessionSettings?: { + agentCodexModel: string; + agentPermissionMode: AgentPermissionMode; + agentCodexReasoningEffort: AgentCodexReasoningEffort; + }) => void; } const AGENT_TYPES: AgentTypeKey[] = [ @@ -232,14 +225,7 @@ const SKILL_CATALOG_MAX_AGE_MS = 60 * 1000; const pendingModelLoads = new Map>(); const pendingSkillLoads = new Map>(); let pendingPiModeInitialization: Promise | null = null; -let pendingLegacyPreferencesInitialization: Promise | null = null; - -async function readLegacyPiModePreference(): Promise<'code' | 'writing' | undefined> { - const legacy = (await agentWorkspace.getLegacyPreferences())?.pi; - if (legacy?.piCodeMode !== undefined) return legacy.piCodeMode ? 'code' : 'writing'; - const mode: unknown = legacy?.mode; - return mode === 'code' || mode === 'writing' ? mode : undefined; -} +let pendingLegacySessionInitialization: Promise | null = null; function skillLoadKey(agentType: AgentTypeKey, notebookId: string): string { return `${agentType}:${notebookId}`; @@ -504,26 +490,6 @@ function persistedAgentDataState(state: AgentDataStoreState): Record()( persist( (set, get) => ({ @@ -539,14 +505,11 @@ export const useAgentDataStore = create()( set((state) => ({ loadState: { ...state.loadState, piMode: { status: "loading" } } })); const request = (async () => { if (get().agents.pi.user.mode === undefined) { - const [features, legacyMode] = await Promise.all([ - piModels.getFeatures(), - get().legacyPreferencesMigrated ? undefined : readLegacyPiModePreference(), - ]); + const features = await piModels.getFeatures(); // Another window may have migrated and cleared the old source already. await useAgentDataStore.persist.rehydrate(); - const importedMode = legacyMode ?? (features.legacyCodeMode === undefined - ? undefined : features.legacyCodeMode ? "code" : "writing"); + const importedMode = features.legacyCodeMode === undefined + ? undefined : features.legacyCodeMode ? "code" : "writing"; set((state) => { // A choice made during this read always wins over migration. const previous = state.agents.pi.user; @@ -583,14 +546,12 @@ export const useAgentDataStore = create()( return request; }, - initializeLegacyPreferences: () => { + initializeLegacySessionSettings: () => { if (get().legacyPreferencesMigrated) return Promise.resolve(); - if (pendingLegacyPreferencesInitialization) return pendingLegacyPreferencesInitialization; + if (pendingLegacySessionInitialization) return pendingLegacySessionInitialization; const request = (async () => { - const legacy = await agentWorkspace.getLegacyPreferences(); const { useAgentSessionStore } = await import('./agent-session-store'); - get().migrateLegacyPreferences( - legacy ?? undefined, + get().migrateLegacySessionSettings( useAgentSessionStore.getState().sessionMeta.settings, ); try { @@ -601,8 +562,8 @@ export const useAgentDataStore = create()( } catch (error) { logger.warn('Agent preference migration persistence check failed', { error }); } - })().finally(() => { pendingLegacyPreferencesInitialization = null; }); - pendingLegacyPreferencesInitialization = request; + })().finally(() => { pendingLegacySessionInitialization = null; }); + pendingLegacySessionInitialization = request; return request; }, @@ -747,59 +708,48 @@ export const useAgentDataStore = create()( get().setUserPreference(agentType, { reasoningEffort: effort, reasoningByModel }); }, - migrateLegacyPreferences: (runtime, codexSessionSettings) => { + migrateLegacySessionSettings: (codexSessionSettings) => { if (get().legacyPreferencesMigrated) return; - if (!runtime && !codexSessionSettings) { + if (!codexSessionSettings) { set({ legacyPreferencesMigrated: true }); return; } set((state) => { const agents = { ...state.agents }; const userPreferenceRevisions = { ...state.userPreferenceRevisions }; - for (const agentType of AGENT_TYPES) { - const legacyRuntimeKey = agentType === "claudecode" ? "claude" : agentType === "dsh" ? "deepseek-harness" : agentType; - const legacy = runtime?.[legacyRuntimeKey]; - const sessionFallback: AgentUserPreference = agentType === "codex" && codexSessionSettings + const migrated: AgentUserPreference = { + ...(codexSessionSettings.agentCodexModel && codexSessionSettings.agentCodexModel !== "inherit" + ? { model: { id: codexSessionSettings.agentCodexModel } } + : {}), + ...(codexSessionSettings.agentPermissionMode + ? { permission: codexSessionSettings.agentPermissionMode } + : {}), + ...(codexSessionSettings.agentCodexReasoningEffort ? { + reasoningEffort: codexSessionSettings.agentCodexReasoningEffort, ...(codexSessionSettings.agentCodexModel && codexSessionSettings.agentCodexModel !== "inherit" - ? { model: { id: codexSessionSettings.agentCodexModel } } - : {}), - ...(codexSessionSettings.agentPermissionMode - ? { permission: codexSessionSettings.agentPermissionMode } - : {}), - ...(codexSessionSettings.agentCodexReasoningEffort - ? { - reasoningEffort: codexSessionSettings.agentCodexReasoningEffort, - ...(codexSessionSettings.agentCodexModel && codexSessionSettings.agentCodexModel !== "inherit" - ? { reasoningByModel: { - [modelPreferenceKey({ id: codexSessionSettings.agentCodexModel })]: codexSessionSettings.agentCodexReasoningEffort, - } } - : {}), - } + ? { reasoningByModel: { + [modelPreferenceKey({ id: codexSessionSettings.agentCodexModel })]: codexSessionSettings.agentCodexReasoningEffort, + } } : {}), } - : {}; - const migrated = legacy - ? { ...sessionFallback, ...mapLegacyRuntimePreference(legacy) } - : sessionFallback; - if (!Object.keys(migrated).length) continue; - const previousUser = agents[agentType].user; - const nextUser = { - ...migrated, - ...previousUser, - ...(migrated.reasoningByModel || previousUser.reasoningByModel - ? { reasoningByModel: { ...migrated.reasoningByModel, ...previousUser.reasoningByModel } } - : {}), - }; - userPreferenceRevisions[agentType] = stampLegacyPreferenceFields( - userPreferenceRevisions[agentType] ?? {}, + : {}), + }; + const previousUser = agents.codex.user; + const nextUser = { + ...migrated, + ...previousUser, + ...(migrated.reasoningByModel || previousUser.reasoningByModel + ? { reasoningByModel: { ...migrated.reasoningByModel, ...previousUser.reasoningByModel } } + : {}), + }; + if (Object.keys(migrated).length) { + userPreferenceRevisions.codex = stampLegacyPreferenceFields( + userPreferenceRevisions.codex ?? {}, previousUser, nextUser, ); - agents[agentType] = { - ...agents[agentType], - user: nextUser, - }; + agents.codex = { ...agents.codex, user: nextUser }; } return { agents, userPreferenceRevisions, legacyPreferencesMigrated: true }; }); @@ -838,11 +788,11 @@ export const useAgentDataStore = create()( ), ); -/** Resolve legacy defaults before a runtime config is built for a request. */ +/** Resolve retained session settings and Pi mode before a runtime config is built. */ export async function ensureAgentPreferencesReady(agentType?: AgentTypeKey): Promise { const state = useAgentDataStore.getState(); - await state.initializeLegacyPreferences().catch((error) => { - logger.warn("Agent preference migration deferred", { error }); + await state.initializeLegacySessionSettings().catch((error) => { + logger.warn("Agent session settings migration deferred", { error }); }); if (!agentType || agentType === "pi") { await state.initializePiMode(); diff --git a/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts b/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts index 2887151a6..5e5f53650 100644 --- a/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts +++ b/app/flowix-web/features/agent/store/agent-workspace-slice.test.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentWorkspaceSnapshot, LegacyAgentRuntimePreferences, NotebookWorkspaceConfig } from "@/lib/types/agent-workspace"; +import type { AgentWorkspaceSnapshot, NotebookWorkspaceConfig } from "@/lib/types/agent-workspace"; import { STORAGE_KEYS } from "@/lib/constants"; -const backend = vi.hoisted(() => ({ get: vi.fn(), getLegacyPreferences: vi.fn(), attachDirectory: vi.fn(), detachDirectory: vi.fn(), pickDirectory: vi.fn() })); +const backend = vi.hoisted(() => ({ get: vi.fn(), attachDirectory: vi.fn(), detachDirectory: vi.fn(), pickDirectory: vi.fn() })); const piBackend = vi.hoisted(() => ({ getFeatures: vi.fn(), clearLegacyCodeMode: vi.fn() })); +const sessionState = vi.hoisted(() => ({ settings: {} as Record })); vi.mock("@platform/tauri/client", () => ({ agentWorkspace: backend, piModels: piBackend })); -vi.mock("./agent-session-store", () => ({ useAgentSessionStore: { getState: () => ({ sessionMeta: { settings: {} } }) } })); +vi.mock("./agent-session-store", () => ({ useAgentSessionStore: { getState: () => ({ sessionMeta: { settings: sessionState.settings } }) } })); import { ensureAgentPreferencesReady, useAgentDataStore } from "./agent-data-store"; import { ensureAgentWorkspaceReady, useAgentWorkspaceStore } from "./agent-workspace-store"; @@ -21,7 +22,7 @@ beforeEach(() => { useAgentWorkspaceStore.setState(useAgentWorkspaceStore.getInitialState(), true); vi.resetAllMocks(); backend.get.mockImplementation(async () => snapshot()); - backend.getLegacyPreferences.mockResolvedValue(null); + sessionState.settings = {}; piBackend.getFeatures.mockResolvedValue({ toolSearch: false }); piBackend.clearLegacyCodeMode.mockResolvedValue(undefined); }); @@ -91,8 +92,8 @@ describe("Pi mode startup migration", () => { expect(piBackend.clearLegacyCodeMode).toHaveBeenCalledOnce(); }); - it("preserves the former Agent runtime default ahead of the Pi feature fallback", async () => { - backend.getLegacyPreferences.mockResolvedValue({ pi: { piCodeMode: true } }); + it("uses the Pi feature's retained mode without Agent runtime defaults", async () => { + piBackend.getFeatures.mockResolvedValue({ toolSearch: false, legacyCodeMode: true }); await useAgentDataStore.getState().initializePiMode(); expect(useAgentDataStore.getState().agents.pi.user.mode).toBe("code"); }); @@ -189,20 +190,25 @@ describe("local.db workspace projection", () => { finish(snapshot()); await reading; expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([]); }); - it("migrates old user preferences without writing the legacy JSON", async () => { - backend.getLegacyPreferences.mockResolvedValue({codex:{model:{key:'legacy-model'}}}); - await useAgentDataStore.getState().initializeLegacyPreferences(); + it("migrates Codex session settings into the unified user preference store", async () => { + sessionState.settings = { + agentCodexModel: "legacy-model", + agentPermissionMode: "workspace-write", + agentCodexReasoningEffort: "high", + }; + await useAgentDataStore.getState().initializeLegacySessionSettings(); expect(useAgentDataStore.getState().agents.codex.user.model?.id).toBe('legacy-model'); + expect(useAgentDataStore.getState().agents.codex.user.permission).toBe("workspace-write"); + expect(useAgentDataStore.getState().agents.codex.user.reasoningEffort).toBe("high"); expect(JSON.parse(localStorage.getItem(STORAGE_KEYS.AGENT_DATA)!).state.legacyPreferencesMigrated).toBe(true); }); - it("waits for legacy preference migration before preparing an Agent request", async () => { - let finish!: (value: LegacyAgentRuntimePreferences | null) => void; - backend.getLegacyPreferences.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); - const ready = ensureAgentPreferencesReady("codex"); - await Promise.resolve(); - expect(useAgentDataStore.getState().agents.codex.user.model).toBeUndefined(); - finish({ codex: { model: { key: "legacy-model" } } }); - await ready; + it("applies retained Codex session settings before preparing an Agent request", async () => { + sessionState.settings = { + agentCodexModel: "legacy-model", + agentPermissionMode: "workspace-write", + agentCodexReasoningEffort: "medium", + }; + await ensureAgentPreferencesReady("codex"); expect(useAgentDataStore.getState().agents.codex.user.model?.id).toBe("legacy-model"); }); it("reports a database failure and retries", async () => { @@ -223,11 +229,9 @@ it("waits for initial database hydration before allowing a workspace snapshot", expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([resource.path]); }); -it("loads database mounts even when importing old preferences fails", async () => { - backend.getLegacyPreferences.mockRejectedValue(new Error('legacy JSON unavailable')); +it("loads database mounts independently from session preference initialization", async () => { expect(await useAgentWorkspaceStore.getState().workspaceActions.loadInitial()).toBe(true); - await expect(useAgentDataStore.getState().initializeLegacyPreferences()).rejects.toThrow('legacy JSON unavailable'); - expect(useAgentDataStore.getState().legacyPreferencesMigrated).toBe(false); + await ensureAgentPreferencesReady("codex"); expect(useAgentWorkspaceStore.getState().workspaceActions.getNotebookFiles('one')?.folders).toEqual([resource.path]); }); diff --git a/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts b/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts index ed6833c30..11f069d7a 100644 --- a/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts +++ b/app/flowix-web/features/agent/thread-card/agent-thread-card.test.ts @@ -79,7 +79,7 @@ vi.mock("@platform/tauri/client", () => ({ getClaudeSessionId: vi.fn(async () => null), getCodexDefaultModel: vi.fn(async () => "gpt-5.5"), }, - agentWorkspace: { getLegacyPreferences: vi.fn(async () => null) }, + agentWorkspace: {}, piModels: { getFeatures: vi.fn(async () => ({ toolSearch: false })), clearLegacyCodeMode: vi.fn(async () => undefined), diff --git a/app/flowix-web/features/editor/extensions/paste-rules/rules.ts b/app/flowix-web/features/editor/extensions/paste-rules/rules.ts index d423e0f77..e0348be9b 100644 --- a/app/flowix-web/features/editor/extensions/paste-rules/rules.ts +++ b/app/flowix-web/features/editor/extensions/paste-rules/rules.ts @@ -14,7 +14,7 @@ import { parseMarkdownForPaste, } from '@features/editor/extensions/paste-rules/markdown'; import { HTML_TABLE_RE, isStandaloneHtmlTable } from '@features/editor/extensions/paste-rules/html'; -import { hasImportableHtml } from '@features/editor/extensions/paste-rules/html-sanitizer'; +import { hasImportableHtml, sanitizeExternalHtml } from '@features/editor/extensions/paste-rules/html-sanitizer'; import { htmlTableToTableContent, looksLikeTsvTable, @@ -221,7 +221,7 @@ export function createManagedPasteRules(options: { priority: 760, match: ({ html, editor }) => !!editor.schema.nodes.table && HTML_TABLE_RE.test(html) && isStandaloneHtmlTable(html), run: ({ html, editor }) => { - const content = htmlTableToTableContent(html); + const content = htmlTableToTableContent(sanitizeExternalHtml(html), editor.schema); if (!content) return 'default'; return insertPastedContent(content, editor) ? 'handled' : 'default'; }, diff --git a/app/flowix-web/features/editor/extensions/paste-rules/table.ts b/app/flowix-web/features/editor/extensions/paste-rules/table.ts index b2ebf77d1..79e85575c 100644 --- a/app/flowix-web/features/editor/extensions/paste-rules/table.ts +++ b/app/flowix-web/features/editor/extensions/paste-rules/table.ts @@ -1,4 +1,5 @@ import type { JSONContent } from '@tiptap/core'; +import { DOMParser as SchemaDOMParser, type Schema } from '@tiptap/pm/model'; import { sanitizeLinkHref } from '@/lib/safe-link'; function splitTsvLine(line: string): string[] { @@ -95,11 +96,25 @@ function readCellContent(cell: HTMLTableCellElement): JSONContent[] { return paragraphs.length ? paragraphs : [{ type: 'paragraph' }]; } -export function htmlTableToTableContent(html: string): JSONContent | null { +export function htmlTableToTableContent(html: string, schema?: Schema): JSONContent | null { const doc = new DOMParser().parseFromString(html, 'text/html'); const table = doc.querySelector('table'); if (!table) return null; + if (schema) { + const parsed = SchemaDOMParser.fromSchema(schema).parse(doc.body); + let result: JSONContent | null = null; + parsed.descendants(node => { + if (result) return false; + if (node.type.name === 'table') { + result = node.toJSON(); + return false; + } + return true; + }); + return result; + } + const rows = Array.from(table.rows) .map(row => Array.from(row.cells).map(readCellContent)) .filter(row => row.length > 0); diff --git a/app/flowix-web/features/editor/extensions/tab-character.ts b/app/flowix-web/features/editor/extensions/tab-character.ts index e969c8539..7a01dcf2a 100644 --- a/app/flowix-web/features/editor/extensions/tab-character.ts +++ b/app/flowix-web/features/editor/extensions/tab-character.ts @@ -35,6 +35,7 @@ function getListItemType(selection: TextSelection): 'listItem' | 'taskItem' | nu const { $from } = selection; for (let depth = $from.depth; depth > 0; depth -= 1) { const typeName = $from.node(depth).type.name; + if (TABLE_CELL_TYPES.has(typeName)) return null; if (LIST_ITEM_TYPES.has(typeName)) { return typeName as 'listItem' | 'taskItem'; } @@ -100,7 +101,6 @@ export const TabCharacter = Extension.create({ const { selection } = view.state; if (!(selection instanceof TextSelection)) return false; - if (isInsideTableCell(selection)) return false; const listItemType = getListItemType(selection); if (listItemType) { @@ -114,6 +114,12 @@ export const TabCharacter = Extension.create({ return true; } + // Lists own Tab even inside a cell, including when an item cannot + // indent further. Only ordinary cell content falls through to table navigation. + if (isInsideTableCell(selection)) { + pendingTab = null; + return false; + } if (event.shiftKey) return false; const now = Date.now(); diff --git a/app/flowix-web/features/editor/extensions/table/flowix-table.test.ts b/app/flowix-web/features/editor/extensions/table/flowix-table.test.ts index f505841bd..6e0ff72ba 100644 --- a/app/flowix-web/features/editor/extensions/table/flowix-table.test.ts +++ b/app/flowix-web/features/editor/extensions/table/flowix-table.test.ts @@ -1,15 +1,18 @@ -import { Editor } from '@tiptap/core'; +import { Editor, type JSONContent } from '@tiptap/core'; +import { TaskList, TaskItem } from '@tiptap/extension-list'; +import { sanitizeExternalHtml } from '../paste-rules/html-sanitizer'; +import { htmlTableToTableContent } from '../paste-rules/table'; import StarterKit from '@tiptap/starter-kit'; import { Markdown } from '@tiptap/markdown'; import { afterEach, describe, expect, it } from 'vitest'; -import { TablePlugin } from '@features/editor/extensions/table/table-plugin'; +import { createTablePlugin } from '@features/editor/extensions/table/table-plugin'; const editors: Editor[] = []; function createEditor(content: string): Editor { const editor = new Editor({ - extensions: [StarterKit, TablePlugin, Markdown], + extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true }), createTablePlugin(), Markdown], content, contentType: 'markdown', }); @@ -103,3 +106,66 @@ describe('Flowix table column widths', () => { expect(editor.getMarkdown()).toContain('"rowHeader":false'); }); }); + + +describe('complex table HTML persistence', () => { + const paragraph = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] }); + const bullet = { type: 'bulletList', content: [{ type: 'listItem', content: [paragraph('哈哈'), + { type: 'orderedList', attrs: { start: 3 }, content: [{ type: 'listItem', content: [paragraph('nested | ')] }] }, + ] }] }; + const tasks = { type: 'taskList', content: [true, false].map(checked => ({ + type: 'taskItem', attrs: { checked }, content: [paragraph('todo'), + { type: 'taskList', content: [{ type: 'taskItem', attrs: { checked: !checked }, content: [paragraph('child')] }] }, + ], + })) }; + + it.each([bullet, tasks, { type: 'paragraph', content: [{ type: 'text', text: 'first' }] }])( + 'preserves complex cells, attributes and neighboring Markdown across repeated saves: $type', block => { + const editor = createEditor(''); + editor.commands.setContent({ type: 'doc', content: [paragraph('before'), { type: 'table', content: [ + { type: 'tableRow', content: [{ type: 'tableHeader', attrs: { colwidth: [180] }, content: [paragraph('header')] }] }, + { type: 'tableRow', content: [{ type: 'tableCell', content: [block, paragraph('after block')] }] }, + ] }, paragraph('after')] }); + const expected = editor.getJSON(); + for (let i = 0; i < 2; i++) { + const markdown = editor.getMarkdown(); + expect(markdown).toContain(' { + const editor = createEditor(''); + editor.commands.setContent({ type: 'doc', content: [{ type: 'table', content: [ + { type: 'tableRow', content: [{ type: 'tableCell', attrs: { colwidth: [160], colspan: 2 }, content: [block] }] }, + ] }] }); + const table: JSONContent | undefined = editor.getJSON().content?.[0]; + const markdown = editor.getMarkdown(); + expect(markdown).toContain(' { + const first = createEditor('
  • first

'); + const second = createEditor('

one

two

'); + expect(first.getMarkdown()).toContain('
    '); + second.destroy(); + expect(first.getMarkdown()).toContain('
      '); + first.commands.setContent('

      simple

      ', { contentType: 'markdown' }); + expect(first.getMarkdown()).not.toContain(' { + const editor = createEditor('| Header |\n| --- |\n| - 哈哈 |'); + expect(editor.getMarkdown()).not.toContain(' (row.content ?? []).some(cell => + cell.content?.length !== 1 || cell.content[0]?.type !== 'paragraph', + )); +} + export const FlowixTable = Table.extend({ parseMarkdown(this: { parent?: (token: MarkdownToken, helpers: MarkdownParseHelpers) => JSONContent }, token, helpers) { const parsed = this.parent ? this.parent(token, helpers) : { type: 'table', content: [] }; @@ -289,3 +299,25 @@ export const FlowixTable = Table.extend({ }, }, }); + +/** Capture the active editor schema; Markdown callbacks have no editor context. */ +export function createEditorTable() { + let editor: Editor; + const renderSimple = getExtensionField(FlowixTable, 'renderMarkdown'); + return Table.extend({ + ...FlowixTable.config, + onBeforeCreate() { + editor = this.editor; + }, + renderMarkdown(node, helpers, ctx) { + if (!tableNeedsHtml(node)) return renderSimple(node, helpers, ctx); + const schema = editor.schema; + const container = document.createElement('div'); + container.appendChild(DOMSerializer.fromSchema(schema).serializeNode(schema.nodeFromJSON(node))); + // A blank source line ends a CommonMark HTML block. Character references + // keep the table on one source line and decode back to literal newlines + // inside pre/code when the schema parses the HTML. + return container.innerHTML.replace(/\r/g, ' ').replace(/\n/g, ' '); + }, + }); +} diff --git a/app/flowix-web/features/editor/extensions/table/table-plugin.ts b/app/flowix-web/features/editor/extensions/table/table-plugin.ts index 821b2e0f3..63d4c5cf6 100644 --- a/app/flowix-web/features/editor/extensions/table/table-plugin.ts +++ b/app/flowix-web/features/editor/extensions/table/table-plugin.ts @@ -4,28 +4,28 @@ import { TableHeader } from '@tiptap/extension-table-header'; import { TableRow } from '@tiptap/extension-table-row'; import { createTableEdgeInsertPlugin } from '@features/editor/extensions/table/table-edge-insert-plugin'; -import { FlowixTable } from '@features/editor/extensions/table/flowix-table'; +import { createEditorTable } from '@features/editor/extensions/table/flowix-table'; const TABLE_RESIZE_HANDLE_WIDTH = 8; const TABLE_CELL_MIN_WIDTH = 80; -export const TablePlugin = Extension.create({ - name: 'tablePlugin', +/** Each editor owns its table renderer and schema binding. */ +export function createTablePlugin() { + const table = createEditorTable().configure({ + resizable: true, + handleWidth: TABLE_RESIZE_HANDLE_WIDTH, + cellMinWidth: TABLE_CELL_MIN_WIDTH, + }); - addExtensions() { - return [ - FlowixTable.configure({ - resizable: true, - handleWidth: TABLE_RESIZE_HANDLE_WIDTH, - cellMinWidth: TABLE_CELL_MIN_WIDTH, - }), - TableRow, - TableHeader, - TableCell, - ]; - }, + return Extension.create({ + name: 'tablePlugin', - addProseMirrorPlugins() { - return [createTableEdgeInsertPlugin(this.editor)]; - }, -}); + addExtensions() { + return [table, TableRow, TableHeader, TableCell]; + }, + + addProseMirrorPlugins() { + return [createTableEdgeInsertPlugin(this.editor)]; + }, + }); +} diff --git a/app/flowix-web/features/editor/markdown-editor-extensions.ts b/app/flowix-web/features/editor/markdown-editor-extensions.ts index 9b26c1f97..ff899a87b 100644 --- a/app/flowix-web/features/editor/markdown-editor-extensions.ts +++ b/app/flowix-web/features/editor/markdown-editor-extensions.ts @@ -30,7 +30,8 @@ import { SlashMenu } from '@features/editor/extensions/slash-menu'; import { AgentThreadCard } from '@features/agent/thread-card'; import { TabAgentRun } from '@features/editor/extensions/tab-agent-run'; import { TabCharacter } from '@features/editor/extensions/tab-character'; -import { TablePlugin } from '@features/editor/extensions/table/table-plugin'; +import { tableNeedsHtml } from '@features/editor/extensions/table/flowix-table'; +import { createTablePlugin } from '@features/editor/extensions/table/table-plugin'; import { StableCaret } from '@features/editor/extensions/stable-caret'; import { MarkdownParagraph, MarkdownTaskItem } from './markdown-parser-common-extensions'; import type { WorkspaceHostId } from '@features/workspace/public/host-api'; @@ -152,6 +153,9 @@ function markIsBold(mark: { type?: string }): boolean { */ function markAmbiguousBoldRunsAsHtml(node: JSONContent): JSONContent { if (!Array.isArray(node.content)) return node; + // HTML tables serialize native schema nodes, so Markdown-only marks must + // never enter their subtrees (including nested tables). + if (node.type === 'table' && tableNeedsHtml(node)) return node; const content = node.content.map(child => markAmbiguousBoldRunsAsHtml(child)); if (node.type !== 'paragraph' && node.type !== 'heading') { @@ -321,7 +325,7 @@ export function createMarkdownEditorExtensions({ TableReference, MediaLibraryReference, FlowixHighlight.configure({ multicolor: true }), - TablePlugin, + createTablePlugin(), TaskList, PreservedTaskItem.configure({ nested: true }), Markdown.configure({ markedOptions: { gfm: true, breaks: true } }), diff --git a/app/flowix-web/features/editor/markdown-editor.lists.test.tsx b/app/flowix-web/features/editor/markdown-editor.lists.test.tsx index d7ecb3f82..841e69fb3 100644 --- a/app/flowix-web/features/editor/markdown-editor.lists.test.tsx +++ b/app/flowix-web/features/editor/markdown-editor.lists.test.tsx @@ -190,3 +190,135 @@ describe('mixed list Markdown round trips', () => { } }) }) + + +describe('table list persistence in the production editor', () => { + it('restores native lists and interactive Todo state after saving', async () => { + const editor = await mountEditor() + act(() => editor.commands.setContent({ type: 'doc', content: [{ + type: 'table', content: [{ type: 'tableRow', content: [{ + type: 'tableCell', attrs: { colwidth: [200] }, content: [ + { type: 'bulletList', content: [item('哈哈', { type: 'orderedList', attrs: { start: 2 }, content: [item('nested')] })] }, + { type: 'taskList', content: [task('done', true), task('pending')] }, + ], + }] }], + }] })) + const expected = editor.state.doc.firstChild?.toJSON() + const markdown = editor.getMarkdown() + expect(markdown).toContain(' editor.commands.setContent(markdown, { contentType: 'markdown' })) + expect(editor.state.doc.firstChild?.toJSON()).toEqual(expected) + let taskPosition = -1 + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'taskItem' && taskPosition < 0) taskPosition = pos + }) + act(() => { editor.view.dispatch(editor.state.tr.setNodeMarkup(taskPosition, undefined, { checked: false })) }) + const updated = editor.getMarkdown() + act(() => editor.commands.setContent(updated, { contentType: 'markdown' })) + expect(editor.state.doc.nodeAt(taskPosition)?.attrs.checked).toBe(false) + }) +}) + + +describe('Tab priority for lists inside table cells', () => { + const pressTab = (editor: Editor, shiftKey = false) => { + const event = new KeyboardEvent('keydown', { key: 'Tab', code: 'Tab', shiftKey, bubbles: true, cancelable: true }) + act(() => { editor.view.dom.dispatchEvent(event) }) + expect(event.defaultPrevented).toBe(true) + } + const cellPosition = (editor: Editor) => { + const { $from } = editor.state.selection + for (let depth = $from.depth; depth > 0; depth--) { + if (['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) return $from.before(depth) + } + return -1 + } + + it.each(['bulletList', 'orderedList', 'taskList'])('indents and outdents %s before navigating cells', async type => { + const editor = await mountEditor() + const makeItem = type === 'taskList' ? (text: string) => task(text, true) : item + act(() => editor.commands.setContent({ type: 'doc', content: [{ type: 'table', content: [{ + type: 'tableRow', content: [ + { type: 'tableCell', content: [{ type, content: [makeItem('A'), makeItem('B')] }] }, + { type: 'tableCell', content: [paragraph('next')] }, + ], + }] }] })) + let position = -1 + editor.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'B') position = pos }) + act(() => editor.commands.setTextSelection(position)) + const originalCell = cellPosition(editor) + pressTab(editor) + expect(cellPosition(editor)).toBe(originalCell) + const list = editor.state.doc.firstChild?.firstChild?.firstChild?.firstChild + expect(list?.childCount).toBe(1) + expect(list?.firstChild?.lastChild?.type.name).toBe(type) + pressTab(editor, true) + expect(cellPosition(editor)).toBe(originalCell) + expect(editor.state.doc.firstChild?.firstChild?.firstChild?.firstChild?.childCount).toBe(2) + if (type === 'taskList') expect(editor.state.doc.firstChild?.firstChild?.firstChild?.firstChild?.child(1).attrs.checked).toBe(true) + }) + + it('keeps a first list item in its cell when it cannot indent', async () => { + const editor = await mountEditor() + act(() => editor.commands.setContent('
      • only

      next

      ')) + let position = -1 + editor.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'only') position = pos }) + act(() => editor.commands.setTextSelection(position)) + const before = editor.getJSON() + const originalCell = cellPosition(editor) + pressTab(editor) + expect(editor.getJSON()).toEqual(before) + expect(cellPosition(editor)).toBe(originalCell) + }) + + it('still navigates ordinary cells with Tab and Shift-Tab', async () => { + const editor = await mountEditor() + act(() => editor.commands.setContent('

      first

      next

      ')) + let position = -1 + editor.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'first') position = pos }) + act(() => editor.commands.setTextSelection(position)) + const originalCell = cellPosition(editor) + pressTab(editor) + expect(cellPosition(editor)).not.toBe(originalCell) + pressTab(editor, true) + expect(cellPosition(editor)).toBe(originalCell) + }) +}) + + +describe('complex table serialization regressions', () => { + it('preserves punctuation-adjacent bold without introducing a non-schema mark', async () => { + const editor = await mountEditor() + const boldParagraph = { type: 'paragraph', content: [ + { type: 'text', text: '重点。', marks: [{ type: 'bold' }] }, + { type: 'text', text: '接下来' }, + ] } + act(() => editor.commands.setContent({ type: 'doc', content: [boldParagraph, { type: 'table', content: [{ + type: 'tableRow', content: [{ type: 'tableCell', content: [ + { type: 'bulletList', content: [{ type: 'listItem', content: [boldParagraph] }] }, + ] }], + }] }] })) + const before = editor.getJSON() + const markdown = editor.getMarkdown() + expect(markdown).toContain('重点。接下来') + act(() => editor.commands.setContent(markdown, { contentType: 'markdown' })) + expect(editor.getJSON()).toEqual(before) + }) + + it('preserves blank lines in a cell code block and following content', async () => { + const editor = await mountEditor() + act(() => editor.commands.setContent({ type: 'doc', content: [{ type: 'table', content: [{ + type: 'tableRow', content: [ + { type: 'tableCell', content: [{ type: 'codeBlock', content: [{ type: 'text', text: 'first\n\nsecond\n & ' }] }] }, + { type: 'tableCell', content: [paragraph('neighbor')] }, + ], + }] }, paragraph('after table')] })) + const before = editor.getJSON() + for (let i = 0; i < 2; i++) { + const markdown = editor.getMarkdown() + expect(markdown).toContain('first second') + act(() => editor.commands.setContent(markdown, { contentType: 'markdown' })) + expect(editor.getJSON()).toEqual(before) + } + }) +}) diff --git a/app/flowix-web/lib/types/agent-workspace.test-support.ts b/app/flowix-web/lib/types/agent-workspace.test-support.ts index 1c760d9ef..b1ccec0ec 100644 --- a/app/flowix-web/lib/types/agent-workspace.test-support.ts +++ b/app/flowix-web/lib/types/agent-workspace.test-support.ts @@ -1,6 +1,5 @@ -/** Historical config fixtures retained for legacy-session regression tests. */ -import type { AgentTypeKey, FilesConfig } from '@/types/agent'; -import type { LegacyAgentRuntimePreference } from './agent-workspace'; +/** Historical config fixtures retained for workspace import regression tests. */ +import type { FilesConfig } from '@/types/agent'; export type AgentAccessKind = "notebook" | "folder"; @@ -28,7 +27,6 @@ export interface AgentAccessEntry { export type AgentAccessFilesDefaults = Record; export interface AgentAccessDefaults { - runtime?: Partial>; files?: AgentAccessFilesDefaults; } diff --git a/app/flowix-web/lib/types/agent-workspace.ts b/app/flowix-web/lib/types/agent-workspace.ts index e3003847e..5573dfca8 100644 --- a/app/flowix-web/lib/types/agent-workspace.ts +++ b/app/flowix-web/lib/types/agent-workspace.ts @@ -1,24 +1,4 @@ -/** Device-local workspace DTOs; runtime preference types support upgrades. */ - -import type { - AgentCodexReasoningEffort, - AgentHarnessPreset, - AgentPermissionMode, -} from "@/types/agent"; - -export interface LegacyAgentRuntimePreference { - model?: { key: string; providerId?: string }; - access?: { sandbox: AgentPermissionMode }; - reasoningEffort?: AgentCodexReasoningEffort; - serviceTier?: "auto" | "priority"; - /** DeepSeek Harness tool presentation mode. */ - mode?: AgentHarnessPreset; - /** Default Pi codemode setting for newly created conversations. */ - piCodeMode?: boolean; -} - -/** Legacy preference payload is a string-keyed map because its keys migrate. */ -export type LegacyAgentRuntimePreferences = Record; +/** Device-local workspace DTOs. */ /** Notebook-local mount data read from local.db. The notebook itself is cwd; * `addDirs` contains only additional runtime roots. */ diff --git a/app/flowix-web/platform/tauri/client/general.ts b/app/flowix-web/platform/tauri/client/general.ts index cd6028283..3a7ccb487 100644 --- a/app/flowix-web/platform/tauri/client/general.ts +++ b/app/flowix-web/platform/tauri/client/general.ts @@ -303,7 +303,6 @@ export interface DeepSeekHarnessPluginCatalog { // Device-local notebook workspaces. Mutations change one attachment atomically. export const agentWorkspace = { get: () => invoke('get_agent_workspace'), - getLegacyPreferences: () => invoke('get_legacy_agent_preferences'), pickDirectory: (notebookId: string) => invoke('pick_notebook_directory', { notebookId }), attachDirectory: (notebookId: string, path: string) => invoke('attach_notebook_directory', { notebookId, path }), detachDirectory: (notebookId: string, path: string) => invoke('detach_notebook_directory', { notebookId, path }), diff --git a/docs/AGENT-DATA.md b/docs/AGENT-DATA.md index fe438d1ee..4a084739a 100644 --- a/docs/AGENT-DATA.md +++ b/docs/AGENT-DATA.md @@ -16,9 +16,9 @@ Flowix file IPC authorizes registered notebook roots and enabled mounted directo ## Upgrade imports - `index.db` is retired and is no longer opened or imported. After successful version initialization, copies in `~/.flowix/` and `~/.flowix/db/`, including SQLite companions, are moved to each directory's `rebuild-backups//` for manual recovery. Installations with only an old index must re-register their notebook folders. -- Each notebook's `.flowix/agent.json` is imported once. Older `agent-access.json.defaults.files` is a fallback for notebooks registered before the upgrade. Unattached global folder entries never create mounts. -- Missing notebook roots defer the mount import. Malformed legacy files fail without marking import complete, allowing a later retry. Successfully imported sources are not reread after mounts change. -- Old runtime defaults are exposed through a migration-only IPC and imported into the browser-owned user preference Store. +- `.flowix/agent.json` is deprecated and ignored. Its contents do not create workspace mounts. +- The startup `agent_workspace_v1` job imports legacy `agent-access.json.defaults.files` into SQLite for notebooks registered at migration time. `_global` defaults apply only to those existing notebooks; unavailable notebook roots defer the job, and later notebooks do not inherit those defaults. Orphan global folder entries do not create mounts. +- Successfully imported mount defaults are not reread after workspace settings change. `defaults.runtime` is no longer read or imported. Current Agent preferences remain in the browser-owned user preference Store; Codex session settings and Pi feature mode are independent legacy inputs. Legacy files are retained on disk for recovery, but normal workspace writes use only `local.db`.