@@ -864,7 +864,7 @@ pub fn initialize(app: &AppHandle) -> Result<AppState, Box<dyn std::error::Error |
| 864 | 864 | } |
| 865 | 865 | |
| 866 | 866 | let active_turns = Arc::new(DashMap::new()); |
| 867 | | - let active_ptys = Arc::new(DashMap::new()); |
| 867 | + let active_ptys: Arc<DashMap<String, PtyEntry>> = Arc::new(DashMap::new()); |
| 868 | 868 | |
| 869 | 869 | // Start the watcher, even if projects_root doesn't exist yet — |
| 870 | 870 | // `spawn_watcher` errors in that case so we handle it. |
@@ -872,9 +872,10 @@ pub fn initialize(app: &AppHandle) -> Result<AppState, Box<dyn std::error::Error |
| 872 | 872 | let (handle, mut rx) = spawn_watcher(&projects_root)?; |
| 873 | 873 | let app_clone = app.clone(); |
| 874 | 874 | let cache_clone = cache.clone(); |
| 875 | + let ptys_clone = active_ptys.clone(); |
| 875 | 876 | tauri::async_runtime::spawn(async move { |
| 876 | 877 | while let Some(change) = rx.recv().await { |
| 877 | | - handle_change(&app_clone, &cache_clone, change); |
| 878 | + handle_change(&app_clone, &cache_clone, &ptys_clone, change); |
| 878 | 879 | } |
| 879 | 880 | }); |
| 880 | 881 | handle |
@@ -889,9 +890,10 @@ pub fn initialize(app: &AppHandle) -> Result<AppState, Box<dyn std::error::Error |
| 889 | 890 | let (handle, mut rx) = spawn_watcher(&fallback_root)?; |
| 890 | 891 | let app_clone = app.clone(); |
| 891 | 892 | let cache_clone = cache.clone(); |
| 893 | + let ptys_clone = active_ptys.clone(); |
| 892 | 894 | tauri::async_runtime::spawn(async move { |
| 893 | 895 | while let Some(change) = rx.recv().await { |
| 894 | | - handle_change(&app_clone, &cache_clone, change); |
| 896 | + handle_change(&app_clone, &cache_clone, &ptys_clone, change); |
| 895 | 897 | } |
| 896 | 898 | }); |
| 897 | 899 | handle |
@@ -909,7 +911,22 @@ pub fn initialize(app: &AppHandle) -> Result<AppState, Box<dyn std::error::Error |
| 909 | 911 | }) |
| 910 | 912 | } |
| 911 | 913 | |
| 912 | | -fn handle_change(app: &AppHandle, cache: &SummaryCache, change: SessionChange) { |
| 914 | +fn handle_change( |
| 915 | + app: &AppHandle, |
| 916 | + cache: &SummaryCache, |
| 917 | + active_ptys: &Arc<DashMap<String, PtyEntry>>, |
| 918 | + change: SessionChange, |
| 919 | +) { |
| 920 | + // When a PTY is actively writing to this session's JSONL file, |
| 921 | + // we'd otherwise re-enter the rescan → list_projects → |
| 922 | + // summarize → sidebar re-render cycle every few hundred ms for |
| 923 | + // the duration of the subprocess's run. The PTY is already the |
| 924 | + // canonical writer and the user sees it via the xterm view, so |
| 925 | + // there's nothing new for the sidebar to learn. Skip the event |
| 926 | + // entirely for PTY-owned sessions to cut the write-storm at the |
| 927 | + // source. Cache invalidation still runs so non-PTY callers |
| 928 | + // (e.g. a manual rescan) see fresh data. |
| 929 | + let skip_emit = path_matches_live_pty(&change, active_ptys); |
| 913 | 930 | match &change { |
| 914 | 931 | SessionChange::Removed(path) => cache.remove(path), |
| 915 | 932 | SessionChange::Added(path) | SessionChange::Modified(path) => { |
@@ -917,14 +934,41 @@ fn handle_change(app: &AppHandle, cache: &SummaryCache, change: SessionChange) { |
| 917 | 934 | cache.remove(path); |
| 918 | 935 | } |
| 919 | 936 | } |
| 920 | | - // Tell the webview something changed. v0 frontend responds with |
| 921 | | - // a `rescan` invocation. |
| 937 | + if skip_emit { |
| 938 | + return; |
| 939 | + } |
| 940 | + // Tell the webview something changed. The frontend debounces |
| 941 | + // and responds with a `rescan` invocation. |
| 922 | 942 | let payload = ChangePayload::from(&change); |
| 923 | 943 | if let Err(e) = app.emit("sessions:changed", payload) { |
| 924 | 944 | tracing::warn!(error = %e, "failed to emit sessions:changed"); |
| 925 | 945 | } |
| 926 | 946 | } |
| 927 | 947 | |
| 948 | +/// Return true if the file path carrying this change is the JSONL |
| 949 | +/// transcript of a session currently being driven by a live PTY. |
| 950 | +/// Used by `handle_change` to suppress file-watcher events for |
| 951 | +/// actively-written sessions so they don't thrash the sidebar. |
| 952 | +fn path_matches_live_pty( |
| 953 | + change: &SessionChange, |
| 954 | + active_ptys: &Arc<DashMap<String, PtyEntry>>, |
| 955 | +) -> bool { |
| 956 | + let path = match change { |
| 957 | + SessionChange::Added(p) |
| 958 | + | SessionChange::Modified(p) |
| 959 | + | SessionChange::Removed(p) => p, |
| 960 | + }; |
| 961 | + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { |
| 962 | + return false; |
| 963 | + }; |
| 964 | + for entry in active_ptys.iter() { |
| 965 | + if entry.session_id.as_deref() == Some(stem) { |
| 966 | + return true; |
| 967 | + } |
| 968 | + } |
| 969 | + false |
| 970 | +} |
| 971 | + |
| 928 | 972 | #[derive(Debug, Clone, serde::Serialize)] |
| 929 | 973 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 930 | 974 | enum ChangePayload { |