Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev
This commit is contained in:
@@ -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"] }
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user