Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev

This commit is contained in:
Shantur Rathore
2026-04-16 08:43:33 +01:00
4 changed files with 487 additions and 160 deletions

View File

@@ -28,4 +28,4 @@ url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [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"] }

View File

@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::env; use std::env;
#[cfg(windows)]
use std::ffi::c_void;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
#[cfg(windows)]
use std::mem::{size_of, zeroed};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@@ -19,12 +23,95 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt; 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)] #[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000; 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<Self> {
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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
@@ -363,6 +450,8 @@ impl Default for CliStatus {
pub struct CliProcessManager { pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>, child: Arc<Mutex<Option<Child>>>,
#[cfg(windows)]
job: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
} }
@@ -372,6 +461,8 @@ impl CliProcessManager {
Self { Self {
status: Arc::new(Mutex::new(CliStatus::default())), status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)), child: Arc::new(Mutex::new(None)),
#[cfg(windows)]
job: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)), ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)),
} }
@@ -394,6 +485,8 @@ impl CliProcessManager {
let status_arc = self.status.clone(); let status_arc = self.status.clone();
let child_arc = self.child.clone(); let child_arc = self.child.clone();
#[cfg(windows)]
let job_arc = self.job.clone();
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
@@ -401,6 +494,8 @@ impl CliProcessManager {
app.clone(), app.clone(),
status_arc.clone(), status_arc.clone(),
child_arc, child_arc,
#[cfg(windows)]
job_arc,
ready_flag, ready_flag,
token_arc, token_arc,
dev, dev,
@@ -420,11 +515,12 @@ impl CliProcessManager {
} }
pub fn stop(&self) -> anyhow::Result<()> { pub fn stop(&self) -> anyhow::Result<()> {
#[cfg(windows)]
let _job = self.job.lock().take();
let mut child_opt = self.child.lock(); let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() { if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; let pid = child.id() as i32;
@@ -446,18 +542,16 @@ impl CliProcessManager {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
#[cfg(windows)] #[cfg(windows)]
if !forced_tree_shutdown if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!( log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}", "regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS, CLI_WINDOWS_FORCE_GRACE_MS,
child.id() child.id()
)); ));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) { if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill(); let _ = child.kill();
} }
break;
} }
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
@@ -476,11 +570,7 @@ impl CliProcessManager {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !forced_tree_shutdown if !kill_process_tree_windows(child.id(), true) {
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill(); let _ = child.kill();
} }
} }
@@ -491,6 +581,9 @@ impl CliProcessManager {
Err(_) => break, 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(); let mut status = self.status.lock();
@@ -511,6 +604,7 @@ impl CliProcessManager {
app: AppHandle, app: AppHandle,
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>, child_holder: Arc<Mutex<Option<Child>>>,
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool, dev: bool,
@@ -592,6 +686,22 @@ impl CliProcessManager {
let pid = child.id(); let pid = child.id();
log_line(&format!("spawned pid={pid}")); 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(); let mut locked = status.lock();
locked.pid = Some(pid); locked.pid = Some(pid);
@@ -665,6 +775,8 @@ impl CliProcessManager {
let status_clone = status.clone(); let status_clone = status.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone(); let child_holder_clone = child_holder.clone();
#[cfg(windows)]
let job_holder_clone = job_holder.clone();
thread::spawn(move || { thread::spawn(move || {
let timeout = Duration::from_secs(60); let timeout = Duration::from_secs(60);
thread::sleep(timeout); thread::sleep(timeout);
@@ -719,6 +831,10 @@ impl CliProcessManager {
// Drop the handle after the process exits so other callers // Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process. // don't attempt to stop/kill a finished process.
*guard = None; *guard = None;
#[cfg(windows)]
{
let _ = job_holder_clone.lock().take();
}
Some(status) Some(status)
} }
None => None, None => None,
@@ -776,7 +892,8 @@ impl CliProcessManager {
auth_cookie_name: &str, auth_cookie_name: &str,
) { ) {
let mut buffer = String::new(); 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:"; let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop { loop {
@@ -818,7 +935,6 @@ impl CliProcessManager {
); );
continue; continue;
} }
} }
} }
Err(_) => break, Err(_) => break,
@@ -1022,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let cwd = std::env::current_dir().ok(); let cwd = std::env::current_dir().ok();
let workspace = workspace_root(); let workspace = workspace_root();
let mut candidates = vec![ let mut candidates = vec![
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")), .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.js")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), .map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), .map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")), .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 workspace
.as_ref() .as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")), .map(|p| p.join("node_modules/tsx/dist/cli.mjs")),

View File

@@ -1,4 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" 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 { Portal } from "solid-js/web"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
@@ -55,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500 const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10 const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000 const ABSOLUTE_TOKEN_CAP = 10000
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -67,6 +69,13 @@ interface PendingSegment {
hasPrimaryText: boolean hasPrimaryText: boolean
} }
interface TimelineSegmentState {
deleteHovered: boolean
deleteSelected: boolean
hasActivePermission: boolean
hidden: boolean
}
function truncateText(value: string): string { function truncateText(value: string): string {
if (value.length <= MAX_TOOLTIP_LENGTH) { if (value.length <= MAX_TOOLTIP_LENGTH) {
return value return value
@@ -352,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
} }
const clearHoverPreview = () => {
clearHoverTimer()
clearCloseTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
}
const scheduleClose = () => { const scheduleClose = () => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
@@ -359,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// Small delay so the pointer can travel from the segment to the tooltip. // Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => { closeTimer = window.setTimeout(() => {
closeTimer = null closeTimer = null
setHoveredSegment(null) clearHoverPreview()
setHoverAnchorRect(null)
}, 160) }, 160)
} }
@@ -400,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
onCleanup(() => { onCleanup(() => {
clearHoverTimer() clearHoverPreview()
clearCloseTimer()
}) })
// --- Selection & histogram rib state --- // --- Selection & histogram rib state ---
@@ -419,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// on activation, resize, or expansion — NOT on every scroll frame. // on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({}) const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined
@@ -450,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
const handleScroll = () => { const handleScroll = () => {
if (renderVirtualizedTimeline()) {
if (hoveredSegment()) {
clearHoverPreview()
}
return
}
if (!isSelectionActive()) return if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
@@ -478,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
}) })
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
createEffect(on(renderVirtualizedTimeline, () => {
clearHoverPreview()
}))
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for // Compute fresh char counts from the store. segment.totalChars can be stale for
@@ -580,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
wasLongPress = true wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge. // 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 let anchorOffset: number | null = null
if (btn && scrollContainerRef) { if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
@@ -632,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
createEffect(on(() => props.activeSegmentId, (activeId) => { createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const element = buttonRefs.get(activeId)
if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { 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" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, 120) : null }, 120) : null
onCleanup(() => { onCleanup(() => {
@@ -685,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return map return map
}) })
const segmentIndexById = createMemo(() => {
const map = new Map<string, number>()
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<string, TimelineSegmentState>()
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<string, string>()
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 ( return (
<div class="message-timeline-container"> <div class="message-timeline-container">
<div <div
ref={scrollContainerRef} ref={(element) => {
scrollContainerRef = element
setScrollElement(element)
}}
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
role="navigation" role="navigation"
aria-label={t("messageTimeline.ariaLabel")} aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll} onScroll={handleScroll}
> >
<For each={props.segments}> <Show
{(segment, segIndex) => { when={renderVirtualizedTimeline()}
onCleanup(() => buttonRefs.delete(segment.id)) fallback={(
<For each={props.segments}>
{(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 <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<div class="message-timeline-item">
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={(event) => {
if (wasLongPress) {
wasLongPress = false
return
}
const btn = buttonRefs.get(segment.id)
const stableBtn = renderVirtualizedTimeline() ? null : btn
let anchorOffset: number | null = null
if (stableBtn && scrollContainerRef) {
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</For>
)}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
{(segment, index) => {
const segIndex = () => index()
const isActive = () => props.activeSegmentId === segment.id const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id) const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => { const isDeleteHovered = () => state().deleteHovered
const hover = deleteHover() as DeleteHoverState const isDeleteSelected = () => state().deleteSelected
if (hover.kind === "message") { const hasActivePermission = () => state().hasActivePermission
return hover.messageId === segment.messageId const isHidden = () => state().hidden
}
if (hover.kind === "deleteUpTo") {
const indexMap = messageIdToSessionIndex()
const targetIndex = indexMap.get(hover.messageId)
if (targetIndex === undefined) return false
const segmentIndex = indexMap.get(segment.messageId)
if (segmentIndex === undefined) return false
return segmentIndex >= targetIndex
}
return false
}
const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their // Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency). // assistant. Uses messageId for correctness (not positional adjacency).
@@ -747,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none" return "none"
} }
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
}
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
if (hasActivePermission()) { if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
} }
return segment.shortLabel ?? getToolIcon("tool") return segment.shortLabel ?? getToolIcon("tool")
@@ -768,73 +975,68 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
if (segment.type === "user") { if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" /> return <UserIcon class="message-timeline-icon" aria-hidden="true" />
} }
return <BotIcon class="message-timeline-icon" aria-hidden="true" /> return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
return ( return (
<button <div class="message-timeline-item">
ref={(el) => registerButtonRef(segment.id, el)} <div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
type="button" <button
data-variant={segment.variant} type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} onClick={(event) => {
onClick={(event) => { if (wasLongPress) {
if (wasLongPress) { wasLongPress = false
wasLongPress = false return
return
}
// Capture scroll anchor before selection changes may toggle
// tool segment visibility, which shifts timeline layout.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
// In selection mode, plain click scrolls to the message
// instead of clearing. Selection is cleared by clicking
// anywhere inside the chat container or pressing Esc.
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
// Restore scroll anchor: keep the clicked badge at the same
// visual position after hidden tools appear or disappear.
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
} }
}
}} const btn = buttonRefs.get(segment.id)
onPointerDown={(e) => handlePointerDown(segment, e)} const stableBtn = renderVirtualizedTimeline() ? null : btn
onPointerUp={handlePointerUp} let anchorOffset: number | null = null
onPointerCancel={handlePointerUp} if (stableBtn && scrollContainerRef) {
onPointerMove={handlePointerMove} anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
onContextMenu={handleContextMenu} }
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> if (event.shiftKey) {
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span> props.onSelectRange?.(segment.id)
</button> } else if (event.ctrlKey || event.metaKey) {
) props.onToggleSelection?.(segment.id)
}} } else if (isMultiSelectActive) {
</For> props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</Virtualizer>
</Show>
<Show when={previewData()}> <Show when={previewData()}>
{(data) => { {(data) => {
onCleanup(() => setTooltipElement(null)) onCleanup(() => setTooltipElement(null))

View File

@@ -66,10 +66,11 @@
} }
.message-timeline { .message-timeline {
--message-timeline-segment-gap: 0.35rem;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0;
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto; overflow-y: auto;
overflow-x: visible; overflow-x: visible;
@@ -114,6 +115,17 @@
-webkit-touch-callout: none; -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 { .message-timeline-segment[data-delete-hover="true"]::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -319,18 +331,7 @@
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); 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 /* Spacing is rendered by the measured item wrapper so virtua can account for it. */
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;
}
.message-timeline-container { .message-timeline-container {
position: relative; position: relative;