diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index 4840e5b..58c1252 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -465,8 +465,19 @@ export const ChatInterface = React.memo(({ assistant }) => { }); visibleMessages.forEach((message: Message) => { if (message.type === "ai") { - const toolCallsWithStatus = getMessageToolCalls(message).map( - (toolCall, toolCallIndex) => { + const toolCallsWithStatus = getMessageToolCalls(message) + // The auxiliary tool-selector's internal `ToolSelectionResponse` call + // has no result and isn't HITL-gated. Surface it only as a transient + // spinner WHILE the run is actively selecting; hide it once the run + // pauses on an interrupt or settles. Otherwise the execute approval's + // "interrupted" icon leaks onto it (it never gets a result to clear) + // and it lingers instead of disappearing. + .filter( + (toolCall) => + toolCall.name !== "ToolSelectionResponse" || + (isLoading && !interrupt) + ) + .map((toolCall, toolCallIndex) => { const name = toolCall.name || "unknown"; return { id: @@ -474,10 +485,12 @@ export const ChatInterface = React.memo(({ assistant }) => { `${message.id ?? "ai-message"}-tool-${toolCallIndex}-${name}`, name, args: toolCall.args, + // The selector call only survives the filter above while the run is + // actively selecting (!interrupt), so this resolves to a spinner for + // it without a special case. status: interrupt ? "interrupted" : ("pending" as const), } as ToolCall; - } - ); + }); messageMap.set(message.id!, { message, toolCalls: toolCallsWithStatus, @@ -516,7 +529,7 @@ export const ChatInterface = React.memo(({ assistant }) => { showAvatar: data.message.type !== prevMessage?.type, }; }); - }, [messages, interrupt, stream]); + }, [messages, interrupt, isLoading, stream]); const groupedTodos = { in_progress: todos.filter((t) => t.status === "in_progress"), diff --git a/src/app/components/MemoryPanel.tsx b/src/app/components/MemoryPanel.tsx index 2906efb..414e44d 100644 --- a/src/app/components/MemoryPanel.tsx +++ b/src/app/components/MemoryPanel.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; import { ArrowLeft, BrainCircuit, @@ -120,6 +127,33 @@ function formatTime(ms: number): string { } } +const MEMORY_LIST_MIN_WIDTH = 200; +const MEMORY_LIST_DEFAULT_WIDTH = 256; // matches the old fixed `md:w-64` +const MEMORY_LIST_WIDTH_KEY = "evoscientist-memory-list-width"; + +/** Clamp the list width to [min, container − 360] so the viewer always has room. + * Falls back to a fixed cap when the container isn't measured yet. */ +function clampListWidth(w: number, container: HTMLElement | null): number { + const max = container + ? Math.max(MEMORY_LIST_MIN_WIDTH + 80, container.clientWidth - 360) + : 640; + return Math.min(Math.max(w, MEMORY_LIST_MIN_WIDTH), max); +} + +/** True on md+ viewports (matches Tailwind's `md:` breakpoint = 768px). Starts + * false (SSR-safe) and corrects on mount. */ +function useIsDesktop(): boolean { + const [isDesktop, setIsDesktop] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(min-width: 768px)"); + const update = () => setIsDesktop(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); + return isDesktop; +} + export function MemoryPanel() { const [listing, setListing] = useState(null); const [loading, setLoading] = useState(true); @@ -146,6 +180,82 @@ export function MemoryPanel() { // Monotonic id so a slow file fetch can't overwrite a newer selection. const reqRef = useRef(0); + // --- Resizable file-list / viewer split (desktop only) ------------------- + const isDesktop = useIsDesktop(); + const splitContainerRef = useRef(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + const roRef = useRef(null); + const [listWidth, setListWidth] = useState(() => { + if (typeof window === "undefined") return MEMORY_LIST_DEFAULT_WIDTH; + const saved = Number(window.localStorage.getItem(MEMORY_LIST_WIDTH_KEY)); + return Number.isFinite(saved) && saved >= MEMORY_LIST_MIN_WIDTH + ? saved + : MEMORY_LIST_DEFAULT_WIDTH; + }); + useEffect(() => { + try { + window.localStorage.setItem(MEMORY_LIST_WIDTH_KEY, String(listWidth)); + } catch { + // localStorage unavailable (e.g. private mode) — width just won't persist. + } + }, [listWidth]); + // Callback ref for the split container: attach a ResizeObserver the moment it + // mounts (the list renders only after data loads, so a plain effect could run + // before the node exists). The observer fires once on observe — clamping a + // persisted width too wide for the current window — and again on every resize. + // Desktop-only (the width isn't applied on mobile); disconnects on unmount. + const setSplitContainer = useCallback((node: HTMLDivElement | null) => { + splitContainerRef.current = node; + roRef.current?.disconnect(); + roRef.current = null; + if (node && typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => { + if (window.matchMedia("(min-width: 768px)").matches) { + setListWidth((w) => clampListWidth(w, node)); + } + }); + ro.observe(node); + roRef.current = ro; + } + }, []); + // Tear down an in-progress drag if the component unmounts mid-drag. + useEffect(() => () => dragCleanupRef.current?.(), []); + const onDividerPointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + dragCleanupRef.current?.(); // defensive: end any stray prior drag + const startX = e.clientX; + const startW = listWidth; + const controller = new AbortController(); + const cleanup = () => { + controller.abort(); // removes every listener added with this signal + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + dragCleanupRef.current = null; + }; + const move = (ev: PointerEvent) => { + setListWidth( + clampListWidth( + startW + (ev.clientX - startX), + splitContainerRef.current + ) + ); + }; + const opts = { signal: controller.signal }; + // Track on window so the drag continues even if the pointer leaves the thin + // handle; pointercancel / blur also end it so the global cursor + selection + // lock and the listeners never get stuck. + window.addEventListener("pointermove", move, opts); + window.addEventListener("pointerup", cleanup, opts); + window.addEventListener("pointercancel", cleanup, opts); + window.addEventListener("blur", cleanup, opts); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + dragCleanupRef.current = cleanup; + }, + [listWidth] + ); + const load = useCallback(async () => { setLoading(true); setError(null); @@ -401,13 +511,17 @@ export function MemoryPanel() { ) : listing && listing.entries.length === 0 ? ( ) : ( -
+
{/* File list */} + {/* Draggable divider between list and viewer (desktop only). */} +
+ {/* Viewer / editor */}