From 8ffb30e70cce373857a736a6549233feb806c714 Mon Sep 17 00:00:00 2001 From: yuxino Date: Mon, 21 Sep 2026 23:13:21 +0800 Subject: [PATCH] fix(session): preserve attachment previews in temporary tasks and forks Allow explicit file previews without requiring a project. Copy referenced pasted inputs into each fork so its transcript remains readable independently of the source task. Keep scratch access scoped to the current session and remove copied inputs if fork publication fails. Cover preview navigation, bounded forks, retained context, source deletion and rollback. --- .../src/components/workpanel/FilesTab.tsx | 24 +- crates/host-core/src/sessions.rs | 155 ++++++++- crates/host-core/src/sessions/fork_files.rs | 324 ++++++++++++++++++ ...3-independent-conversation-session-fork.md | 16 +- docs/spec/03-runtime/04-data-storage.md | 6 + docs/spec/04-ux/01-ui-ia.md | 4 + docs/spec/06-delivery/04-e2e-test-plan.md | 16 + docs/spec/08-meta/decisions-log.md | 2 +- docs/zh-CN/spec/03-runtime/04-data-storage.md | 5 + docs/zh-CN/spec/04-ux/01-ui-ia.md | 3 + .../spec/06-delivery/04-e2e-test-plan.md | 13 + docs/zh-CN/spec/08-meta/decisions-log.md | 2 +- scripts/e2e/composer-paste.tsx | 34 ++ 13 files changed, 579 insertions(+), 25 deletions(-) create mode 100644 crates/host-core/src/sessions/fork_files.rs diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index 582f34bb4f..ad0c17493e 100644 --- a/apps/desktop/src/components/workpanel/FilesTab.tsx +++ b/apps/desktop/src/components/workpanel/FilesTab.tsx @@ -220,7 +220,7 @@ export function FilesTab() { // so "back" lands on a tree that reveals it. Attachment blobs and absolute // scratch paths live outside the workspace tree. useEffect(() => { - if (!fileRequest || !root) return; + if (!fileRequest) return; if (fileRequest.seq === handledFileRequestSeq) return; handledFileRequestSeq = fileRequest.seq; const path = fileRequest.path; @@ -229,7 +229,7 @@ export function FilesTab() { path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); - if (!isExternal) { + if (root && !isExternal) { const parts = path.split("/").slice(0, -1); const ancestors: string[] = []; let acc = ""; @@ -305,16 +305,6 @@ export function FilesTab() { }); }; - if (!root) { - return ( - - ); - } - if (selected !== null) { return (
@@ -375,5 +365,15 @@ export function FilesTab() { ); } + if (!root) { + return ( + + ); + } + return
{renderDir("", 0)}
; } diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index c94245a5e6..f8e794fb41 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -11,6 +11,8 @@ use std::sync::{Mutex, OnceLock}; use crate::transcripts::{self, CompactionRecord, MessageRecord, RevisionRecord}; +mod fork_files; + pub const MODES: [&str; 3] = ["plan", "goal", "agent"]; /// Maximum number of Unicode scalar values accepted for a user-defined title. @@ -1481,8 +1483,9 @@ pub fn get_session_with_options( } /// Create an independent session from the source session's current canonical -/// transcript. Regenerate revisions, turns, artifacts, notifications, scratch -/// data, and live runtime state are intentionally not copied. +/// transcript and referenced pasted inputs. Regenerate revisions, turns, +/// artifacts, notifications, other scratch data, and live runtime state are +/// intentionally not copied. pub enum ForkSessionResult { Created(Box), NotFound, @@ -1536,15 +1539,22 @@ pub fn fork_session_through( source_records.truncate(position + 1); } let source_compactions = transcripts::read_compactions(db.data_dir(), source_id)?; - let (records, message_ids, tool_call_ids) = clone_records_for_fork(source_records); + let (mut records, message_ids, tool_call_ids) = clone_records_for_fork(source_records); // Each checkpoint is remapped on its own: a message-scoped fork can cut the // anchor of a later checkpoint while the earlier ones stay intact. - let compactions: Vec = source_compactions + let mut compactions: Vec = source_compactions .into_iter() .filter_map(|record| clone_compaction_for_fork(record, &message_ids, &tool_call_ids)) .collect(); - let texts = records.iter().map(record_index_text).collect::>(); let id = Uuid::new_v4().to_string(); + let files = fork_files::preserve( + db.data_dir(), + source_id, + &id, + &mut records, + &mut compactions, + )?; + let texts = records.iter().map(record_index_text).collect::>(); let now = now_ms(); let created_at = ms_to_ts(now); let requested_title = title.map(str::trim).filter(|value| !value.is_empty()); @@ -1553,13 +1563,16 @@ pub fn fork_session_through( .unwrap_or_else(|| format!("{} (branch)", source.summary.title)); invalidate_transcript_layout(&id); - transcripts::write_transcript_with_compactions( + if let Err(error) = transcripts::write_transcript_with_compactions( db.data_dir(), &id, &created_at, &records, &compactions, - )?; + ) { + transcripts::remove_session_files(db.data_dir(), &id); + return Err(error); + } let indexed = (|| -> Result<()> { let tx = db.conn().unchecked_transaction()?; let inserted = tx @@ -1587,6 +1600,7 @@ pub fn fork_session_through( transcripts::remove_session_files(db.data_dir(), &id); return Err(error); } + files.commit(); let summary = SessionSummary { id, @@ -5034,6 +5048,133 @@ mod tests { assert_eq!(detail.messages[1].content, "next"); } + #[test] + fn fork_index_failure_removes_copied_inputs_and_transcript() { + let db = test_db(); + let source = create_session(&db, None, None, None, None, None).unwrap(); + let pasted = crate::scratch::session_dir(db.data_dir(), &source.id) + .unwrap() + .join("pasted"); + std::fs::create_dir_all(&pasted).unwrap(); + let file = pasted.join("note.txt"); + std::fs::write(&file, "keep source").unwrap(); + append_message( + &db, + &source.id, + &user_msg( + "input", + &format!("@{}", file.display()), + "2025-05-01T00:00:00Z", + ), + None, + ) + .unwrap(); + let files_before = std::fs::read_dir(db.data_dir().join("sessions")) + .unwrap() + .count(); + db.conn().execute_batch("CREATE TRIGGER fail_fork BEFORE INSERT ON sessions BEGIN SELECT RAISE(ABORT, 'injected index failure'); END;").unwrap(); + let result = fork_session_through(&db, &source.id, None, None); + assert!(result.is_err()); + assert_eq!( + std::fs::read_dir(db.data_dir().join("sessions")) + .unwrap() + .count(), + files_before + ); + assert_eq!( + std::fs::read_dir(crate::scratch::base_dir(db.data_dir())) + .unwrap() + .count(), + 1 + ); + assert_eq!(std::fs::read_to_string(file).unwrap(), "keep source"); + } + + #[test] + fn fork_preserves_referenced_pasted_files_independently() { + let db = test_db(); + let source = + create_session(&db, Some("Attachments".into()), None, None, None, None).unwrap(); + let scratch = crate::scratch::session_dir(db.data_dir(), &source.id).unwrap(); + let pasted = scratch.join("pasted"); + std::fs::create_dir_all(&pasted).unwrap(); + let first = pasted.join("first note.txt"); + let later = pasted.join("later.png"); + std::fs::write(&first, "original reference bytes").unwrap(); + std::fs::write(&later, b"image bytes").unwrap(); + std::fs::write(pasted.join("unused.txt"), "do not copy").unwrap(); + let first_text = format!("Read @\"{}\"", first.display()); + append_message( + &db, + &source.id, + &user_msg("paste-1", &first_text, "2025-05-01T00:00:00Z"), + None, + ) + .unwrap(); + append_message( + &db, + &source.id, + &user_msg( + "paste-2", + &format!("@{}", later.display()), + "2025-05-01T00:00:01Z", + ), + None, + ) + .unwrap(); + let ForkSessionResult::Created(child) = + fork_session_through(&db, &source.id, None, Some("paste-1")).unwrap() + else { + panic!("expected child") + }; + let child_scratch = crate::scratch::session_dir(db.data_dir(), &child.summary.id).unwrap(); + let child_file = child_scratch.join("pasted/first note.txt"); + assert_eq!( + std::fs::read_to_string(&child_file).unwrap(), + "original reference bytes" + ); + assert_eq!( + child.messages[0].content, + format!("Read @\"{}\"", child_file.display()) + ); + assert!(!child_scratch.join("pasted/later.png").exists()); + assert!(!child_scratch.join("pasted/unused.txt").exists()); + assert_eq!( + get_session(&db, &source.id).unwrap().unwrap().messages[0].content, + first_text + ); + delete_session(&db, &source.id).unwrap(); + crate::scratch::remove_session_dir(db.data_dir(), &source.id); + assert_eq!( + std::fs::read_to_string(&child_file).unwrap(), + "original reference bytes" + ); + assert_eq!( + get_session(&db, &child.summary.id) + .unwrap() + .unwrap() + .messages[0] + .content, + child.messages[0].content + ); + let ForkSessionResult::Created(grandchild) = + fork_session_through(&db, &child.summary.id, None, None).unwrap() + else { + panic!("expected grandchild") + }; + let grandchild_file = crate::scratch::session_dir(db.data_dir(), &grandchild.summary.id) + .unwrap() + .join("pasted/first note.txt"); + assert_eq!( + std::fs::read_to_string(&grandchild_file).unwrap(), + "original reference bytes" + ); + assert_eq!( + grandchild.messages[0].content, + format!("Read @\"{}\"", grandchild_file.display()) + ); + } + #[test] fn fork_session_clones_active_transcript_and_configuration() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/host-core/src/sessions/fork_files.rs b/crates/host-core/src/sessions/fork_files.rs new file mode 100644 index 0000000000..0c82b073d9 --- /dev/null +++ b/crates/host-core/src/sessions/fork_files.rs @@ -0,0 +1,324 @@ +//! Preserve session-owned inputs without inheriting arbitrary scratch outputs. + +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; + +use crate::scratch; +use crate::transcripts::{CompactionRecord, MessageRecord}; + +/// Roll back copied inputs if transcript/index publication fails. +pub(super) struct ForkFiles { + destination: PathBuf, + committed: bool, +} + +impl ForkFiles { + pub(super) fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for ForkFiles { + fn drop(&mut self) { + if !self.committed && self.destination.exists() { + if let Err(error) = fs::remove_dir_all(&self.destination) { + tracing::warn!(%error, "fork attachment rollback failed"); + } + } + } +} + +// A file name must not match a prefix of another file name. Inline references +// may be quoted, Markdown links, or plain paths in model/context text. +fn reference_end(tail: &str) -> bool { + tail.chars().next().is_none_or(|c| { + c.is_whitespace() || matches!(c, '"' | '\'' | '`' | ')' | ']' | '}' | ',' | ';') + }) +} + +fn reference_start(prefix: &str) -> bool { + prefix.chars().next_back().is_none_or(|c| { + c.is_whitespace() || matches!(c, '@' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ':') + }) +} + +fn replace_reference(text: &mut String, source: &str, target: &str) -> bool { + let mut result = String::new(); + let mut start = 0; + let mut changed = false; + for (offset, _) in text.match_indices(source) { + let end = offset + source.len(); + if reference_start(&text[..offset]) && reference_end(&text[end..]) { + result.push_str(&text[start..offset]); + result.push_str(target); + start = end; + changed = true; + } + } + if changed { + result.push_str(&text[start..]); + *text = result; + } + changed +} + +fn rewrite_value(value: &mut Value, source: &str, target: &str) -> bool { + match value { + Value::String(text) => replace_reference(text, source, target), + Value::Array(values) => { + let mut changed = false; + for value in values { + changed |= rewrite_value(value, source, target); + } + changed + } + Value::Object(values) => { + let mut changed = false; + for value in values.values_mut() { + changed |= rewrite_value(value, source, target); + } + changed + } + _ => false, + } +} + +fn rewrite( + records: &mut [MessageRecord], + compactions: &mut [CompactionRecord], + source: &str, + target: &str, +) -> bool { + let mut changed = false; + for record in records { + changed |= rewrite_value(&mut record.blocks, source, target); + if let Some(meta) = &mut record.meta { + changed |= rewrite_value(meta, source, target); + } + } + for record in compactions { + changed |= replace_reference(&mut record.summary, source, target); + for value in [&mut record.retained_tail, &mut record.details] + .into_iter() + .flatten() + { + changed |= rewrite_value(value, source, target); + } + } + changed +} + +pub(super) fn preserve( + data_dir: &Path, + source_id: &str, + child_id: &str, + records: &mut [MessageRecord], + compactions: &mut [CompactionRecord], +) -> Result { + let source = scratch::session_dir(data_dir, source_id) + .ok_or_else(|| anyhow!("invalid source session id"))?; + let destination = scratch::session_dir(data_dir, child_id) + .ok_or_else(|| anyhow!("invalid child session id"))?; + if destination.try_exists()? { + return Err(anyhow!("fork scratch directory already exists")); + } + let guard = ForkFiles { + destination, + committed: false, + }; + let pasted = source.join("pasted"); + // Scratch is disposable: historical inputs that have already expired do + // not prevent branching. Never follow a link out of the owned input tree. + for directory in [scratch::base_dir(data_dir), source, pasted.clone()] { + match fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => return Err(anyhow!("fork input directory is not a regular directory")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(guard), + Err(error) => return Err(error.into()), + } + } + for entry in fs::read_dir(&pasted)? { + let entry = entry?; + let path = entry.path(); + let target = guard.destination.join("pasted").join(entry.file_name()); + let source_path = path.to_string_lossy(); + let target_path = target.to_string_lossy(); + let mut referenced = rewrite(records, compactions, &source_path, &target_path); + if entry.file_type()?.is_file() { + let canonical = path.canonicalize()?; + let canonical_path = canonical.to_string_lossy(); + if canonical_path != source_path { + referenced |= rewrite(records, compactions, &canonical_path, &target_path); + } + } + // Renderer path references may use forward slashes on Windows. + #[cfg(windows)] + { + referenced |= rewrite( + records, + compactions, + &source_path.replace('\\', "/"), + &target_path.replace('\\', "/"), + ); + } + if !referenced { + continue; + } + if !entry.file_type()?.is_file() { + return Err(anyhow!("referenced fork input is not a regular file")); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags( + windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT, + ); + } + let mut input = options.open(&path).context("open fork input")?; + let metadata = input.metadata()?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(anyhow!("referenced fork input is not a regular file")); + } + fs::create_dir_all(guard.destination.join("pasted"))?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .context("create fork input copy")?; + std::io::copy(&mut input, &mut output).context("copy fork input")?; + output.sync_all()?; + } + Ok(guard) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn message(blocks: Value) -> MessageRecord { + MessageRecord { + id: "message".into(), + role: "user".into(), + tool_name: None, + is_error: false, + blocks, + meta: None, + created_at: "2026-09-21T00:00:00Z".into(), + } + } + + #[test] + fn copies_structured_and_compacted_inputs_and_rolls_back_until_committed() { + let dir = tempfile::tempdir().unwrap(); + let pasted = dir.path().join("scratch/source/pasted"); + fs::create_dir_all(&pasted).unwrap(); + let file = pasted.join("input.txt"); + fs::write(&file, "bytes").unwrap(); + let canonical = file.canonicalize().unwrap(); + let mut records = vec![message(json!([{"type":"attachment", "ref": file}]))]; + let mut compactions: Vec = vec![serde_json::from_value(json!({ + "id":"checkpoint", "summary":format!("Read @\"{}\"", canonical.display()), + "throughMessageId":"message", "tokensBefore":10, "createdAt":"now", + "retainedTail":[{"blocks":[{"ref": file}]}], "details":{"file": file} + })) + .unwrap()]; + let guard = preserve( + dir.path(), + "source", + "child", + &mut records, + &mut compactions, + ) + .unwrap(); + let target = dir.path().join("scratch/child/pasted/input.txt"); + assert_eq!(fs::read_to_string(&target).unwrap(), "bytes"); + assert_eq!( + records[0].blocks[0]["ref"], + target.to_string_lossy().as_ref() + ); + assert_eq!( + compactions[0].summary, + format!("Read @\"{}\"", target.display()) + ); + assert_eq!( + compactions[0].retained_tail.as_ref().unwrap()[0]["blocks"][0]["ref"], + target.to_string_lossy().as_ref() + ); + assert_eq!( + compactions[0].details.as_ref().unwrap()["file"], + target.to_string_lossy().as_ref() + ); + drop(guard); + assert!(!dir.path().join("scratch/child").exists()); + assert_eq!(fs::read_to_string(file).unwrap(), "bytes"); + } + + #[test] + fn expired_inputs_do_not_prevent_forking() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("scratch/source/pasted/missing.txt"); + let mut records = vec![message(json!([{"ref":missing}]))]; + preserve(dir.path(), "source", "child", &mut records, &mut []) + .unwrap() + .commit(); + assert_eq!( + records[0].blocks[0]["ref"], + missing.to_string_lossy().as_ref() + ); + assert!(!dir.path().join("scratch/child").exists()); + } + + #[cfg(unix)] + #[test] + fn refuses_referenced_symlinks_and_symlinked_input_directories() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let pasted = dir.path().join("scratch/source/pasted"); + fs::create_dir_all(&pasted).unwrap(); + let external = dir.path().join("private.txt"); + fs::write(&external, "private").unwrap(); + let link = pasted.join("link.txt"); + symlink(&external, &link).unwrap(); + let mut records = vec![message(json!([{"ref":link}]))]; + assert!(preserve(dir.path(), "source", "child", &mut records, &mut []).is_err()); + assert!(!dir.path().join("scratch/child").exists()); + fs::remove_file(link).unwrap(); + fs::remove_dir(&pasted).unwrap(); + symlink(dir.path(), &pasted).unwrap(); + assert!(preserve(dir.path(), "source", "child", &mut [], &mut []).is_err()); + } + + #[test] + fn rewrites_nested_paths_without_matching_file_name_prefixes() { + let mut value = json!({"blocks": [ + {"path": "/scratch/source/pasted/note.txt"}, + {"text": "Read @\"/scratch/source/pasted/note.txt\" twice /scratch/source/pasted/note.txt"}, + {"text": "/scratch/source/pasted/note.txt.bak"} + ]}); + assert!(rewrite_value( + &mut value, + "/scratch/source/pasted/note.txt", + "/child/note.txt" + )); + assert_eq!(value["blocks"][0]["path"], "/child/note.txt"); + assert_eq!( + value["blocks"][1]["text"], + "Read @\"/child/note.txt\" twice /child/note.txt" + ); + assert_eq!( + value["blocks"][2]["text"], + "/scratch/source/pasted/note.txt.bak" + ); + } +} diff --git a/docs/adr/0023-independent-conversation-session-fork.md b/docs/adr/0023-independent-conversation-session-fork.md index 68dd81e593..37ccc942a0 100644 --- a/docs/adr/0023-independent-conversation-session-fork.md +++ b/docs/adr/0023-independent-conversation-session-fork.md @@ -28,7 +28,14 @@ pass startup handshake and fail only when the new command is invoked. `NOT_FOUND`. - The child inherits project, provider, model, mode, thinking, and permission mode. It does not inherit turns, regenerate revisions, notifications, - artifacts, session grants, scratch data, pin state, or live runtime state. + artifacts, session grants, arbitrary scratch outputs, pin state, or live + runtime state. +- Referenced existing files under the source session's `scratch//pasted/` + directory are copied into the child's own input directory. Canonical message + and retained compaction references are rewritten before indexing. This keeps + pasted/imported inputs usable after source deletion without granting access + to another session's scratch directory. Already-expired inputs remain missing; + unrelated scratch outputs and unreferenced files are not inherited. - No parent/child lineage is stored. This is an independent conversation copy, not a message tree and not a replacement for linear regenerate history. - Assistant response Fork uses the bounded snapshot directly. Assistant Edit @@ -39,8 +46,8 @@ pass startup handshake and fail only when the new command is invoked. - Fork is available only while the source is idle. Electron exposes `AGENT_BUSY`; the host retains a persisted running-turn `CONFLICT` guard that Electron normalizes at the IPC boundary. -- A handled file or index failure removes the child transcript and leaves no - visible child. Process crashes continue to follow the transcript store's +- A handled file or index failure removes the child transcript and copied + inputs and leaves no visible child. Process crashes continue to follow the transcript store's existing orphan-file recovery policy. ## Consequences @@ -48,7 +55,8 @@ pass startup handshake and fail only when the new command is invoked. - Renderer and host binaries from protocol v4 are rejected during startup instead of failing lazily when Create branch is selected. - Source and child can evolve, reconfigure, persist, and delete independently. -- Fork storage cost is proportional to the active transcript size. +- Fork storage cost is proportional to the active transcript and its referenced + pasted inputs. - Message-scoped Fork/Edit storage cost is proportional to the canonical prefix through the selected response plus any child-only revision payloads. - Every child has a new session id and first creates/reseeds its own pi runtime; diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index 22dbdcb164..bc25dfc1f6 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -532,6 +532,12 @@ CREATE INDEX idx_session_import_origins_plugin message. Assistant Edit uses that child and records the original/edited response tails in the child's existing `message_revisions` store; the source transcript and source revisions are never rewritten. +- Forks copy existing referenced files from `scratch//pasted/` to + `scratch//pasted/` and rewrite message/checkpoint paths before + indexing. Source deletion cannot remove the child copies. Unreferenced files, + later-message inputs outside a bounded fork, and other scratch outputs are + excluded. Missing expired inputs stay missing; no cross-session read grant + is added. Handled fork failures remove copied inputs and child transcripts. ### 4.6 turns — one row per agent run diff --git a/docs/spec/04-ux/01-ui-ia.md b/docs/spec/04-ux/01-ui-ia.md index e661968c87..aab53e246e 100644 --- a/docs/spec/04-ux/01-ui-ia.md +++ b/docs/spec/04-ux/01-ui-ia.md @@ -221,6 +221,10 @@ destination, chat as the home surface, tools and permissions inline. delete remain separate actions. Rename edits the task label only; archive never removes the transcript. Open folder is a project action, not a conversation action. +- **Temporary-task attachments**: selecting a saved attachment opens its file + preview even without an open project. Back returns to the no-project browsing + state. Branches preserve referenced pasted/imported inputs as child-owned + copies; deleting the source task does not break these previews. - **Sort**: user-facing modes are Recently updated, Created date, Oldest first, and Name. Pinned rows precede unpinned rows. Project groups switch to `manual` by dragging a title or using ArrowUp/ArrowDown on that diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 7359c94bd0..8c6adf9443 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -14347,3 +14347,19 @@ the latest destination. These assertions measure work counts, not device FPS. - **Status:** Automated by `node --experimental-strip-types scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and real Rust/stdio/SQLite. Only external inference is replaced with an observer. + +### E2E-SESSION-temporary-attachment-fork: Preview and independent branch inputs + +- **Steps**: In a task without a project, paste text above the long-paste + threshold, send it, and click its transcript attachment. Return from the + preview, create a branch, and open the same attachment in the child. Delete + the source task and reopen the child attachment; branch the child again. +- **Expected**: Every preview shows the original saved bytes. Each branch path + belongs to that branch's scratch input directory. No project is required, and + no access to another task's scratch directory is granted. A bounded fork + excludes later-only and unreferenced inputs. +- **Automation**: `scripts/e2e-composer-paste.mjs` covers long-text save, real + Electron file read, temporary-task preview, and back navigation. Host tests + `fork_preserves_referenced_pasted_files_independently` and `sessions::fork_files` + cover ownership, deletion, repeated/bounded forks, retained checkpoint paths, + expired inputs, rollback, and symlink rejection. diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 6908216d1e..f8a3ef64c4 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -399,7 +399,7 @@ Gold source: local Codex electron captures; latest row wins where rows conflict. | ID | Topic | Decision | Rationale | |---|---|---|---| | D119 | Transcript file store; SQLite index-only | **Schema v7: message content moves out of SQLite into per-session JSONL files under `~/.pi-desktop/sessions/` — `.jsonl` (a session-header line, then one canonical block-array message line per message, RFC3339 stamps) plus an append-only `.revisions.jsonl` for regenerate branches. `messages` drops `content_json`/`meta_json` and becomes a pure index (ordering, promoted filter columns, extracted `text` feeding FTS); `message_revisions` swaps `messages_json` for `message_count`, with `is_active` tracked in the DB only. Writes are file-first then index transaction; reads skip unknown/torn lines and dedupe repeated message ids keep-last; full rewrites are temp-file + atomic rename; session files are deleted only with their session and never age/orphan-swept. Opening a pre-v7 database archives it as `pi.sqlite.v6.bak` and bootstraps fresh — an explicit breaking reset, with all v1–v6 migration code removed. RPC wire format is unchanged, so Electron/renderer/importers need no changes.** | The database grew without bound carrying tool args/results and thinking payloads; codex/claude-code-style per-session files keep transcripts human-readable, greppable, and portable while SQLite stays a small, fast index (list, search, badges). A dev-phase breaking reset was chosen over migration machinery. | -| D122 | Independent conversation session fork | **Protocol v5 adds host-owned `session.fork`: an idle source's complete active canonical transcript is copied into a new independent session with remapped message/tool-call ids and inherited project/provider/model/mode/thinking/permission configuration. Turns, regenerate revisions, notifications, artifacts, session grants, scratch/runtime state, pin state, and parent-child lineage are not copied. Create branch activates the child; D109 remains unchanged because no message-level branch tree is introduced.** | A single host-owned snapshot preserves canonical blocks and persistence consistency while giving users a Codex-style divergence workflow without conflating independent conversations with regenerate variants. | +| D122 | Independent conversation session fork | **Protocol v5 adds host-owned `session.fork`: an idle source's complete active canonical transcript is copied into a new independent session with remapped message/tool-call ids and inherited project/provider/model/mode/thinking/permission configuration. Turns, regenerate revisions, notifications, artifacts, session grants, arbitrary scratch outputs, runtime state, pin state, and parent-child lineage are not copied. Referenced existing pasted/imported inputs are copied into child-owned scratch files and their transcript/checkpoint paths are remapped (ADR 0023). Create branch activates the child; D109 remains unchanged because no message-level branch tree is introduced.** | A single host-owned snapshot preserves canonical blocks and persistence consistency while giving users a Codex-style divergence workflow without conflating independent conversations with regenerate variants. | | D134 | Assistant response fork and reversible edit | *(edit clause superseded by D137)* **The completed-assistant toolbar exposes Copy, Fork, Edit, and Regenerate but no Delete. Fork calls the existing host-owned `session.fork` with optional `throughMessageId`, producing an independent session whose canonical transcript ends at that response. Edit uses the same isolated child, replaces only the selected assistant text there, and stores original/edited tails as a two-entry D109 revision family so the existing pager can restore either. Both require an idle source, remap message/tool-call ids, and never share the source session id, runtime, transcript, revisions, or provider cache state.** | Response-level divergence and correction should remain reversible without mutating the source or letting an edited history reuse cached runtime state built from different assistant content. | | D199 | Regenerate branch archived under the host RPC lock | **`session.saveActiveRevision` performs the read, the branch archive, and the `revisionCount` / `activeRevision` stamp in one host call under the state lock, replacing Electron main's `session.get` + `session.replaceMessages` read-modify-write. The stamp rewrites only the root user's transcript line and re-reads the file at write time, so a line appended meanwhile survives; Electron main drains the persistence outbox first and skips the archive with a warning rather than archiving an incomplete branch. `session.replaceMessages` carries each surviving message's owning `turn_id` across a rewrite and is documented as safe only for a caller that owns the whole transcript for the call's duration (ADR 0060).** | Assistant and tool messages reach SQLite asynchronously through the ADR 0041 outbox, so the renderer-side snapshot could predate the turn's final message — and the whole-transcript rewrite then deleted it from both the transcript file and the index, along with every row's `turn_id`. Only the host can read and write the transcript atomically. | diff --git a/docs/zh-CN/spec/03-runtime/04-data-storage.md b/docs/zh-CN/spec/03-runtime/04-data-storage.md index eb3af6c29c..8c4f3e51c8 100644 --- a/docs/zh-CN/spec/03-runtime/04-data-storage.md +++ b/docs/zh-CN/spec/03-runtime/04-data-storage.md @@ -443,6 +443,11 @@ CREATE INDEX idx_session_import_origins_plugin 消息。 Assistant Edit 使用该子项并记录 original/edited 子级现有 `message_revisions` 存储中的响应尾部;来源 抄本和源版本的修订永远不会被重写。 +- 分支将已有且被引用的 `scratch//pasted/` 文件复制到 + `scratch//pasted/`,在建立索引前更新消息和检查点中的路径。 + 删除原任务不会删除子任务的副本。未引用文件、截断点之后独有的输入和其他 + scratch 输出不复制;已过期的文件仍不可用,不新增跨任务读取授权。 + 分支失败时清理已复制的输入及子任务转录本。 ### 4.6 turns — 每次 agent 运行一行 diff --git a/docs/zh-CN/spec/04-ux/01-ui-ia.md b/docs/zh-CN/spec/04-ux/01-ui-ia.md index 439efc217c..2c8a5a2136 100644 --- a/docs/zh-CN/spec/04-ux/01-ui-ia.md +++ b/docs/zh-CN/spec/04-ux/01-ui-ia.md @@ -32,6 +32,9 @@ +------------------+--------------------------------+------------------+ ``` +- **临时任务附件**:即使没有打开项目,选择已保存的附件也应打开文件预览; + 返回后恢复无项目的文件浏览空状态。分支保留被引用的粘贴或导入文件的独立副本, + 删除原任务不会破坏分支的附件预览。 - **侧边栏**:主要导航 - 紧凑的无路径对话 **会话** 包含新会话和排序操作的部分,保留开放项目 具有持久性新项目的以下 **Projects** 部分下的组 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index d47163dc04..8728a78afd 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8513,3 +8513,16 @@ the latest destination. These assertions measure work counts, not device FPS. `providers::tests::a_stored_array_survives_an_entry_that_lost_a_field`); 宿主 RPC 路径由 `scripts/e2e-smoke.mjs` 覆盖提供商的创建与列举,但没有套件 驱动手工编辑的 `config_json`。 + +### E2E-SESSION-temporary-attachment-fork:临时任务预览与独立分支附件 + +- **步骤**:在没有项目的任务中粘贴超过长文本阈值的内容并发送,点击对话中的附件。 + 返回后创建分支并打开同一附件;删除原任务,再打开分支附件,并继续创建分支。 +- **预期**:每次预览均显示原始文件内容;各分支引用自身的 scratch 输入目录, + 不需要打开项目,也不授予跨任务读取权限。按消息截断的分支不复制后续消息独有 + 或未引用的输入文件。 +- **自动化**:`scripts/e2e-composer-paste.mjs` 覆盖长文本保存、真实 Electron + 文件读取、临时任务预览及返回。主机测试 + `fork_preserves_referenced_pasted_files_independently` 和 `sessions::fork_files` + 覆盖附件归属、删除、重复与截断分支、保留的压缩检查点引用、过期输入、失败回滚 + 及符号链接拒绝。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index dd2109e308..8e23d82c58 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -401,7 +401,7 @@ | 身份证号 | 主题 | 决定 | 基本原理 | |---|---|---|---| | D119 | 成绩单文件存储; SQLite 仅索引 | **架构 v7:消息内容从 SQLite 移入 `~/.pi-desktop/sessions/` 下的每个会话 JSONL 文件 — `.jsonl`(会话标头行,然后每条消息一个规范的块数组消息行,RFC3339 标记)加上用于重新生成分支的仅附加 `.revisions.jsonl`。 `messages` 删除 `messages`/`content_json` 并成为纯索引(排序、提升过滤列、提取 `text` 馈送 FTS); `message_revisions` 将 `messages_json` 替换为 `message_count`,并且仅在数据库中跟踪 `is_active`。写入首先是文件,然后是索引事务;读取时跳过 unknown/torn 行并删除重复消息 ID keep-last;完全重写是临时文件+原子重命名;会话文件仅与其会话一起删除,而绝不会删除 age/orphan-swept。打开 v7 之前的数据库会将其存档为 `pi.sqlite.v6.bak` 并进行全新引导 — 显式中断重置,删除所有 v1-v6 迁移代码。 RPC 线路格式未更改,因此 Electron/renderer/importers 无需更改。** | 数据库的增长不受携带工具args/results和思考有效负载的限制; codex/claude-code-style 每个会话文件使记录保持人类可读、可 grep 和可移植的状态,而 SQLite 则保持小型、快速的索引(列表、搜索、徽章)。选择了开发阶段中断重置而不是迁移机制。 | -| D122 | 独立对话会话分叉 | **协议 v5 添加了主机拥有的 `session.fork`:将空闲源的完整活动规范转录复制到具有重新映射的 message/tool-call id 和继承的 project/provider/model/mode/thinking/permission 配置的新独立会话中。不会复制轮转、重新生成修订、通知、工件、会话授权、scratch/runtime 状态、引脚状态和父子沿袭。创建分支激活子分支; D109 保持不变,因为没有引入消息级分支树。** | 单个主机拥有的快照保留了规范块和持久性一致性,同时为用户提供了 Codex 风格的发散工作流程,而无需将独立对话与重新生成变体混为一谈。 | +| D122 | 独立对话会话分叉 | **协议 v5 添加了主机拥有的 `session.fork`:将空闲源的完整活动规范转录复制到具有重新映射的 message/tool-call id 和继承的 project/provider/model/mode/thinking/permission 配置的新独立会话中。不会复制轮转、重新生成修订、通知、工件、会话授权、任意 scratch 输出、运行时状态、引脚状态和父子沿袭。被引用且仍存在的粘贴或导入输入会复制到子任务自己的 scratch 文件,并重写转录本及检查点中的路径(ADR 0023)。创建分支激活子分支; D109 保持不变,因为没有引入消息级分支树。** | 单个主机拥有的快照保留了规范块和持久性一致性,同时为用户提供了 Codex 风格的发散工作流程,而无需将独立对话与重新生成变体混为一谈。 | | D134 | 助理响应叉和可逆编辑 | *(由 D137 取代的编辑子句)* **完成的辅助工具栏显示“复制”、“分叉”、“编辑”和“重新生成”,但没有“删除”。 Fork 使用可选的 `throughMessageId` 调用现有主机拥有的 `session.fork`,生成一个独立会话,其规范记录以该响应结束。编辑使用相同的隔离子项,仅替换其中选定的辅助文本,并将 original/edited 尾部存储为两个条目的 D109 修订系列,以便现有寻呼机可以恢复其中之一。两者都需要空闲源、重新映射 message/tool-call id,并且从不共享源会话 id、运行时、转录本、修订版或提供程序缓存状态。** | 响应级别的分歧和纠正应该保持可逆,而不改变源或让编辑的历史重用从不同助手内容构建的缓存运行时状态。 | | D199 | 重新生成主机 RPC 锁下存档的分支 | **`session.saveActiveRevision` 在状态锁下的一次主机调用中执行读取、分支存档和 `revisionCount` / `activeRevision` 标记,替换 Electron 主程序的 `session.get` + `session.replaceMessages` 读取-修改-写入。标记仅重写 root 用户的转录行并在写入时重新读取文件,因此同时附加的行会保留下来; Electron main 首先排空持久性发件箱,并跳过存档并发出警告,而不是存档不完整的分支。 `session.replaceMessages` 在重写过程中携带每条幸存消息所属的 `turn_id`,并且仅对于在呼叫持续时间内拥有整个记录的呼叫者而言才被记录为安全 (ADR 0060)。** | 助手和工具消息通过 ADR 0041 发件箱异步到达 SQLite,因此渲染器端快照可以早于回合的最终消息 - 然后整个转录文本重写从转录文件和索引中将其连同每行的 `turn_id` 一起删除。只有主机可以原子地读取和写入转录本。 | diff --git a/scripts/e2e/composer-paste.tsx b/scripts/e2e/composer-paste.tsx index 7e2be85b46..1bcbc3bc67 100644 --- a/scripts/e2e/composer-paste.tsx +++ b/scripts/e2e/composer-paste.tsx @@ -25,6 +25,7 @@ import { setEditorCaret, } from "../../apps/desktop/src/features/chat/composer/editor"; import { api } from "../../apps/desktop/src/lib/api"; +import { FilesTab } from "../../apps/desktop/src/components/workpanel/FilesTab"; import { readComposerDraft, resetComposerDraftCache, @@ -389,6 +390,38 @@ globalThis.composerPasteProbe = async () => { "large text chip lost the selection boundary", ); + // Preview the persisted long-text attachment through the public work-panel + // entry point, with no project open (the temporary-task user path). + const previewHost = document.createElement("div"); + document.body.append(previewHost); + const previewRoot = createRoot(previewHost); + try { + flushSync(() => previewRoot.render( + , + )); + assert(previewHost.textContent?.includes(i18n.t("panel.files.noWorkspace")), + "file browsing without a project should show the empty state"); + flushSync(() => useAppStore.getState().openFileInWorkPanel( + controller.fileReferences[0].path, "text/plain", + )); + const deadline = performance.now() + 3000; + while (!previewHost.querySelector(".file-viewer-code") && performance.now() < deadline) { + await new Promise(requestAnimationFrame); + } + assert(previewHost.querySelector(".file-viewer-code")?.textContent === longText, + "temporary-task attachment did not display its saved text in the file preview"); + const back = previewHost.querySelector( + `[aria-label="${i18n.t("panel.files.back")}"]`, + ); + assert(back, "file preview must provide back navigation"); + flushSync(() => back!.click()); + assert(previewHost.textContent?.includes(i18n.t("panel.files.noWorkspace")), + "back from a temporary attachment should restore the no-project empty state"); + } finally { + flushSync(() => previewRoot.unmount()); + previewHost.remove(); + } + await paste("", [image]); assert( controller.fileReferences.length === 1 && @@ -774,6 +807,7 @@ globalThis.composerPasteProbe = async () => { fileReferenceUndoRedo: true, crossBreakAndChipSelection: true, mixedLongText: true, + temporaryTaskTextPreview: true, imageOnly: true, nativeImageFile: true, imagePreviewAndKeyboard: true,