From 1907a4da03b2aebe63d81b4d02a55ea97c446928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 11 Apr 2026 23:52:00 +0200 Subject: [PATCH 1/2] perf(ui): virtualize message timeline rendering, #274 follow-up ( BIG SPEED IMPROVEMENT ) (#291) ## Summary - virtualize MessageTimeline so large session histories stop rendering the full timeline sidebar at once. - keep the existing full render path in selection mode so xray/selection behavior stays intact. - route active-segment scrolling through the virtualizer so timeline navigation still follows the selected message. ## Benefit - prompt field was very laggy in cession with big history and timeline had many bugs, this is fixed. - the session with big history now load as fast as a new session . --- .../ui/src/components/message-timeline.tsx | 452 +++++++++++++----- .../src/styles/messaging/message-timeline.css | 27 +- 2 files changed, 341 insertions(+), 138 deletions(-) diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index e2b9fdee..17cd7d10 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -1,4 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Portal } from "solid-js/web" import MessagePreview from "./message-preview" import { messageStoreBus } from "../stores/message-v2/bus" @@ -55,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220 const LONG_PRESS_MS = 500 const JITTER_THRESHOLD = 10 const ABSOLUTE_TOKEN_CAP = 10000 +const TIMELINE_VIRTUALIZER_BUFFER_PX = 240 type ToolCallPart = Extract @@ -67,6 +69,13 @@ interface PendingSegment { hasPrimaryText: boolean } +interface TimelineSegmentState { + deleteHovered: boolean + deleteSelected: boolean + hasActivePermission: boolean + hidden: boolean +} + function truncateText(value: string): string { if (value.length <= MAX_TOOLTIP_LENGTH) { return value @@ -352,6 +361,13 @@ const MessageTimeline: Component = (props) => { } } + const clearHoverPreview = () => { + clearHoverTimer() + clearCloseTimer() + setHoveredSegment(null) + setHoverAnchorRect(null) + } + const scheduleClose = () => { if (typeof window === "undefined") return clearHoverTimer() @@ -359,8 +375,7 @@ const MessageTimeline: Component = (props) => { // Small delay so the pointer can travel from the segment to the tooltip. closeTimer = window.setTimeout(() => { closeTimer = null - setHoveredSegment(null) - setHoverAnchorRect(null) + clearHoverPreview() }, 160) } @@ -400,8 +415,7 @@ const MessageTimeline: Component = (props) => { }) onCleanup(() => { - clearHoverTimer() - clearCloseTimer() + clearHoverPreview() }) // --- Selection & histogram rib state --- @@ -419,6 +433,8 @@ const MessageTimeline: Component = (props) => { // on activation, resize, or expansion — NOT on every scroll frame. const [badgeOffsets, setBadgeOffsets] = createSignal>({}) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) + const [scrollElement, setScrollElement] = createSignal() + const [virtualizerHandle, setVirtualizerHandle] = createSignal() let scrollContainerRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined @@ -450,6 +466,12 @@ const MessageTimeline: Component = (props) => { } const handleScroll = () => { + if (renderVirtualizedTimeline()) { + if (hoveredSegment()) { + clearHoverPreview() + } + return + } if (!isSelectionActive()) return if (!scrollContainerRef || !xrayOverlayRef) return xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) @@ -478,6 +500,12 @@ const MessageTimeline: Component = (props) => { } }) + const renderVirtualizedTimeline = createMemo(() => !isSelectionActive()) + + createEffect(on(renderVirtualizedTimeline, () => { + clearHoverPreview() + })) + const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) // Compute fresh char counts from the store. segment.totalChars can be stale for @@ -580,7 +608,7 @@ const MessageTimeline: Component = (props) => { wasLongPress = true // Scroll anchoring: preserve visual position of the pressed badge. - const btn = buttonRefs.get(segment.id) + const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id) let anchorOffset: number | null = null if (btn && scrollContainerRef) { anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop @@ -632,9 +660,17 @@ const MessageTimeline: Component = (props) => { createEffect(on(() => props.activeSegmentId, (activeId) => { if (!activeId) return - const element = buttonRefs.get(activeId) - if (!element) return const timer = typeof window !== "undefined" ? window.setTimeout(() => { + if (renderVirtualizedTimeline()) { + const index = segmentIndexById().get(activeId) + if (index !== undefined) { + virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true }) + } + return + } + + const element = buttonRefs.get(activeId) + if (!element) return element.scrollIntoView({ block: "nearest", behavior: "smooth" }) }, 120) : null onCleanup(() => { @@ -685,60 +721,239 @@ const MessageTimeline: Component = (props) => { return map }) + const segmentIndexById = createMemo(() => { + const map = new Map() + for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i) + return map + }) + + const segmentStates = createMemo(() => { + const hover = deleteHover() + const selectedMessages = props.selectedMessageIds?.() + const expandedMessages = props.expandedMessageIds?.() + const resolvedStore = store() + const indexMap = messageIdToSessionIndex() + const selectionActive = isSelectionActive() + const result = new Map() + + for (const segment of props.segments) { + let deleteHovered = false + if (hover.kind === "message") { + deleteHovered = hover.messageId === segment.messageId + } else if (hover.kind === "deleteUpTo") { + const targetIndex = indexMap.get(hover.messageId) + const segmentIndex = indexMap.get(segment.messageId) + deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex + } + + const deleteSelected = selectedMessages?.has(segment.messageId) ?? false + + let hasActivePermission = false + if (segment.type === "tool") { + const partIds = segment.toolPartIds ?? [] + for (const partId of partIds) { + const permissionState = resolvedStore.getPermissionState(segment.messageId, partId) + if (permissionState?.active) { + hasActivePermission = true + break + } + } + } + + const hidden = segment.type === "tool" && !( + showTools() + || expandedMessages?.has(segment.messageId) + || selectionActive + || props.activeSegmentId === segment.id + || hasActivePermission + || deleteHovered + || deleteSelected + ) + + result.set(segment.id, { + deleteHovered, + deleteSelected, + hasActivePermission, + hidden, + }) + } + + return result + }) + + const segmentStateFor = (segmentId: string): TimelineSegmentState => { + return segmentStates().get(segmentId) ?? { + deleteHovered: false, + deleteSelected: false, + hasActivePermission: false, + hidden: false, + } + } + + const segmentSpacerHeights = createMemo(() => { + const states = segmentStates() + const result = new Map() + let previousVisible: TimelineSegment | null = null + + for (let index = 0; index < props.segments.length; index += 1) { + const segment = props.segments[index] + const state = states.get(segment.id) + + if (state?.hidden) { + result.set(segment.id, "0") + continue + } + + if (!previousVisible) { + result.set(segment.id, "0") + previousVisible = segment + continue + } + + const previousRaw = index > 0 ? props.segments[index - 1] : null + const startsVisibleToolGroup = segment.type === "tool" + && (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId) + const startsCollapsedToolGroup = segment.type === "assistant" + && previousVisible.messageId !== segment.messageId + && messagesWithTools().has(segment.messageId) + && previousRaw?.type === "tool" + && previousRaw.messageId === segment.messageId + const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction") + && previousVisible.type === "assistant" + && messagesWithTools().has(previousVisible.messageId) + + const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0) + result.set( + segment.id, + gapUnits === 1 + ? "var(--message-timeline-segment-gap)" + : "calc(var(--message-timeline-segment-gap) * 2)", + ) + + previousVisible = segment + } + + return result + }) + return (
{ + scrollContainerRef = element + setScrollElement(element) + }} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} role="navigation" aria-label={t("messageTimeline.ariaLabel")} onScroll={handleScroll} > - - {(segment, segIndex) => { - onCleanup(() => buttonRefs.delete(segment.id)) + + {(segment, segIndex) => { + onCleanup(() => buttonRefs.delete(segment.id)) + const isActive = () => props.activeSegmentId === segment.id + const isSelected = () => props.selectedIds?.().has(segment.id) + const state = () => segmentStateFor(segment.id) + const isDeleteHovered = () => state().deleteHovered + const isDeleteSelected = () => state().deleteSelected + const hasActivePermission = () => state().hasActivePermission + const isHidden = () => state().hidden + + const groupRole = (): "child" | "parent" | "none" => { + if (segment.type === "tool") return "child" + if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" + return "none" + } + + const shortLabelContent = () => { + if (segment.type === "tool") { + if (hasActivePermission()) { + return {(data) => { onCleanup(() => setTooltipElement(null)) diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index 78aeb927..308ae92c 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -66,10 +66,11 @@ } .message-timeline { + --message-timeline-segment-gap: 0.35rem; flex: 1 1 auto; display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0; padding: 0.25rem; overflow-y: auto; overflow-x: visible; @@ -114,6 +115,17 @@ -webkit-touch-callout: none; } +.message-timeline-item { + display: flex; + flex-direction: column; + width: 100%; +} + +.message-timeline-item-spacer { + flex: none; + width: 100%; +} + .message-timeline-segment[data-delete-hover="true"]::before { content: ""; position: absolute; @@ -319,18 +331,7 @@ border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); } -/* Extra spacing before the first tool in a group to separate from the - preceding user/assistant badge. */ -.message-timeline-group-start { - margin-top: 0.35rem; -} - -/* Subtle extra spacing after the group parent (assistant) to separate - from the next user badge below. Uses adjacent sibling targeting. */ -.message-timeline-group-parent + .message-timeline-user, -.message-timeline-group-parent + .message-timeline-compaction { - margin-top: 0.35rem; -} +/* Spacing is rendered by the measured item wrapper so virtua can account for it. */ .message-timeline-container { position: relative; From 1ce58b9dd914e78728eabf40b5fcc645e885300f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 12 Apr 2026 22:10:15 +0200 Subject: [PATCH 2/2] fix(tauri): own Windows CLI subtree with a job object (#320) ## Summary - Follow-up to #240 to make Windows desktop shutdown reliable this time, even when the tracked CLI wrapper PID exits before its descendants - Attach the spawned CLI process to a Windows Job Object with `KILL_ON_JOB_CLOSE`, so the desktop app owns the whole subtree instead of relying only on `taskkill /PID /T` - Keep the current graceful-then-force shutdown path, but add a robust OS-level fallback that reaps orphaned workspace processes when the wrapper is already gone ## Root Cause The previous Windows shutdown logic still depended on the PID tracked by Tauri. In practice that PID can be a short-lived Node wrapper. Once that wrapper exits, `taskkill` can report success or PID-not-found while descendants remain alive, and the desktop app no longer has a reliable handle to reap them. ## Validation - `cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml` - `cargo build --release --manifest-path packages/tauri-app/src-tauri/Cargo.toml` - Manual local test: orphaned processes are cleaned up after desktop shutdown --- packages/tauri-app/src-tauri/Cargo.toml | 2 +- .../tauri-app/src-tauri/src/cli_manager.rs | 166 +++++++++++++++--- 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 13a89b1f..786e7791 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -28,4 +28,4 @@ url = "2" tauri-plugin-notification = "2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] } diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 5844566e..358523e3 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; use std::env; +#[cfg(windows)] +use std::ffi::c_void; use std::ffi::OsStr; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(windows)] +use std::mem::{size_of, zeroed}; use std::net::TcpStream; #[cfg(unix)] use std::os::unix::process::CommandExt; @@ -19,12 +23,95 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; #[cfg(windows)] use std::os::windows::process::CommandExt; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +#[derive(Debug)] +struct WindowsJobObject { + // The desktop wrapper may observe only a short-lived Node wrapper PID while the real + // server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives + // Tauri an OS-owned handle for the whole subtree instead of relying on a single PID. + handle: HANDLE, +} + +#[cfg(windows)] +impl WindowsJobObject { + fn create() -> anyhow::Result { + let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) }; + if handle.is_null() { + return Err(anyhow::anyhow!( + "CreateJobObjectW failed: {}", + std::io::Error::last_os_error() + )); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let ok = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + ) + }; + if ok == 0 { + let err = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err)); + } + + Ok(Self { handle }) + } + + fn assign_child(&self, child: &Child) -> anyhow::Result<()> { + let process_handle = child.as_raw_handle() as HANDLE; + let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) }; + if ok == 0 { + return Err(anyhow::anyhow!( + "AssignProcessToJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for WindowsJobObject { + fn drop(&mut self) { + if !self.handle.is_null() { + unsafe { + CloseHandle(self.handle); + } + } + } +} + +#[cfg(windows)] +unsafe impl Send for WindowsJobObject {} + +#[cfg(windows)] +unsafe impl Sync for WindowsJobObject {} + fn log_line(message: &str) { println!("[tauri-cli] {message}"); } @@ -363,6 +450,8 @@ impl Default for CliStatus { pub struct CliProcessManager { status: Arc>, child: Arc>>, + #[cfg(windows)] + job: Arc>>, ready: Arc, bootstrap_token: Arc>>, } @@ -372,6 +461,8 @@ impl CliProcessManager { Self { status: Arc::new(Mutex::new(CliStatus::default())), child: Arc::new(Mutex::new(None)), + #[cfg(windows)] + job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), } @@ -394,6 +485,8 @@ impl CliProcessManager { let status_arc = self.status.clone(); let child_arc = self.child.clone(); + #[cfg(windows)] + let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { @@ -401,6 +494,8 @@ impl CliProcessManager { app.clone(), status_arc.clone(), child_arc, + #[cfg(windows)] + job_arc, ready_flag, token_arc, dev, @@ -420,11 +515,12 @@ impl CliProcessManager { } pub fn stop(&self) -> anyhow::Result<()> { + #[cfg(windows)] + let _job = self.job.lock().take(); + let mut child_opt = self.child.lock(); if let Some(mut child) = child_opt.take() { log_line(&format!("stopping CLI pid={}", child.id())); - #[cfg(windows)] - let mut forced_tree_shutdown = false; #[cfg(unix)] unsafe { let pid = child.id() as i32; @@ -446,18 +542,16 @@ impl CliProcessManager { Ok(Some(_)) => break, Ok(None) => { #[cfg(windows)] - if !forced_tree_shutdown - && start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) - { + if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) { log_line(&format!( "regular Windows shutdown still running after {}ms; escalating pid={}", CLI_WINDOWS_FORCE_GRACE_MS, child.id() )); - forced_tree_shutdown = true; if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } + break; } if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { @@ -476,11 +570,7 @@ impl CliProcessManager { } #[cfg(windows)] { - if !forced_tree_shutdown - && !kill_process_tree_windows(child.id(), true) - { - let _ = child.kill(); - } else if forced_tree_shutdown { + if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } @@ -491,6 +581,9 @@ impl CliProcessManager { Err(_) => break, } } + } else { + #[cfg(windows)] + log_line("tracked CLI process already exited; dropping Windows job object to reap descendants"); } let mut status = self.status.lock(); @@ -511,6 +604,7 @@ impl CliProcessManager { app: AppHandle, status: Arc>, child_holder: Arc>>, + #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, dev: bool, @@ -592,6 +686,22 @@ impl CliProcessManager { let pid = child.id(); log_line(&format!("spawned pid={pid}")); + #[cfg(windows)] + match WindowsJobObject::create().and_then(|job| { + job.assign_child(&child)?; + Ok(job) + }) { + Ok(job) => { + log_line(&format!("attached pid={pid} to Windows job object")); + *job_holder.lock() = Some(job); + } + Err(err) => { + log_line(&format!( + "failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}" + )); + } + } + { let mut locked = status.lock(); locked.pid = Some(pid); @@ -665,6 +775,8 @@ impl CliProcessManager { let status_clone = status.clone(); let ready_clone = ready.clone(); let child_holder_clone = child_holder.clone(); + #[cfg(windows)] + let job_holder_clone = job_holder.clone(); thread::spawn(move || { let timeout = Duration::from_secs(60); thread::sleep(timeout); @@ -719,6 +831,10 @@ impl CliProcessManager { // Drop the handle after the process exits so other callers // don't attempt to stop/kill a finished process. *guard = None; + #[cfg(windows)] + { + let _ = job_holder_clone.lock().take(); + } Some(status) } None => None, @@ -776,7 +892,8 @@ impl CliProcessManager { auth_cookie_name: &str, ) { let mut buffer = String::new(); - let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok(); + let local_url_regex = + Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok(); let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; loop { @@ -818,7 +935,6 @@ impl CliProcessManager { ); continue; } - } } Err(_) => break, @@ -1022,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option { let cwd = std::env::current_dir().ok(); let workspace = workspace_root(); let mut candidates = vec![ - cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")), - cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")), + cwd.as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.cjs")), cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")), - cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), - cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), - cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")), - cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), - cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), - cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.js")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.js")), workspace .as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.mjs")),