import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import type { SessionStatus } from "../types/session" import type { SessionThread } from "../stores/session-state" import { getSessionStatus } from "../stores/session-status" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid" import KeyboardHint from "./keyboard-hint" import SessionRenameDialog from "./session-rename-dialog" import { keyboardRegistry } from "../lib/keyboard-registry" import { showToastNotification } from "../lib/notifications" import { deleteSession, ensureSessionParentExpanded, getVisibleSessionIds, isSessionParentExpanded, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, toggleSessionParentExpanded, } from "../stores/sessions" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" const log = getLogger("session") interface SessionListProps { instanceId: string threads: SessionThread[] activeSessionId: string | null onSelect: (sessionId: string) => void onNew: () => void showHeader?: boolean showFooter?: boolean headerContent?: JSX.Element footerContent?: JSX.Element } function formatSessionStatus(status: SessionStatus): string { switch (status) { case "working": return "Working" case "compacting": return "Compacting" default: return "Idle" } } const SessionList: Component = (props) => { const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) const isSessionDeleting = (sessionId: string) => { const deleting = loading().deletingSession.get(props.instanceId) return deleting ? deleting.has(sessionId) : false } const selectSession = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const parentId = session?.parentId ?? session?.id if (parentId) { ensureSessionParentExpanded(props.instanceId, parentId) } props.onSelect(sessionId) } const copySessionId = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() try { const success = await copyToClipboard(sessionId) if (success) { showToastNotification({ message: "Session ID copied", variant: "success" }) } else { showToastNotification({ message: "Unable to copy session ID", variant: "error" }) } } catch (error) { log.error(`Failed to copy session ID ${sessionId}:`, error) showToastNotification({ message: "Unable to copy session ID", variant: "error" }) } } const handleDeleteSession = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() if (isSessionDeleting(sessionId)) return const shouldSelectFallback = props.activeSessionId === sessionId let fallbackSessionId: string | undefined if (shouldSelectFallback) { const visible = getVisibleSessionIds(props.instanceId) const currentIndex = visible.indexOf(sessionId) const remaining = visible.filter((id) => id !== sessionId) if (remaining.length > 0) { if (currentIndex !== -1) { for (let i = currentIndex; i < visible.length; i++) { const candidate = visible[i] if (candidate && candidate !== sessionId) { fallbackSessionId = candidate break } } if (!fallbackSessionId) { for (let i = currentIndex - 1; i >= 0; i--) { const candidate = visible[i] if (candidate && candidate !== sessionId) { fallbackSessionId = candidate break } } } } fallbackSessionId ??= remaining[0] } } try { await deleteSession(props.instanceId, sessionId) if (fallbackSessionId) { setActiveSessionFromList(props.instanceId, fallbackSessionId) } } catch (error) { log.error(`Failed to delete session ${sessionId}:`, error) showToastNotification({ message: "Unable to delete session", variant: "error" }) } } const openRenameDialog = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) if (!session) return const label = session.title && session.title.trim() ? session.title : sessionId setRenameTarget({ id: sessionId, title: session.title ?? "", label }) } const closeRenameDialog = () => { setRenameTarget(null) } const handleRenameSubmit = async (nextTitle: string) => { const target = renameTarget() if (!target) return setIsRenaming(true) try { await renameSession(props.instanceId, target.id, nextTitle) setRenameTarget(null) } catch (error) { log.error(`Failed to rename session ${target.id}:`, error) showToastNotification({ message: "Unable to rename session", variant: "error" }) } finally { setIsRenaming(false) } } const SessionRow: Component<{ sessionId: string isChild?: boolean isLastChild?: boolean hasChildren?: boolean expanded?: boolean onToggleExpand?: () => void }> = (rowProps) => { const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId)) if (!session()) { return <> } const isActive = () => props.activeSessionId === rowProps.sessionId const title = () => session()?.title || "Untitled" const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const statusLabel = () => formatSessionStatus(status()) const needsPermission = () => Boolean(session()?.pendingPermission) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsInput = () => needsPermission() || needsQuestion() const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`) const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel()) return (
) } const activeParentId = createMemo(() => { const activeId = props.activeSessionId if (!activeId || activeId === "info") return null const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId) if (!activeSession) return null return activeSession.parentId ?? activeSession.id }) createEffect(() => { const parentId = activeParentId() if (!parentId) return ensureSessionParentExpanded(props.instanceId, parentId) }) const listEl = createSignal(null) const escapeCss = (value: string) => { if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") { return (CSS as any).escape(value) } return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"") } const scrollActiveIntoView = (sessionId: string) => { const root = listEl[0]() if (!root) return const selector = `[data-session-id="${escapeCss(sessionId)}"]` const scrollNow = () => { const target = root.querySelector(selector) as HTMLElement | null if (!target) return target.scrollIntoView({ block: "nearest", inline: "nearest" }) } if (typeof requestAnimationFrame === "undefined") { scrollNow() return } // Wait a couple frames so expand/collapse DOM settles. let raf1 = 0 let raf2 = 0 raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { scrollNow() }) }) onCleanup(() => { if (raf1) cancelAnimationFrame(raf1) if (raf2) cancelAnimationFrame(raf2) }) } createEffect(() => { const activeId = props.activeSessionId if (!activeId || activeId === "info") return scrollActiveIntoView(activeId) }) return (
{props.headerContent ?? (

Sessions

)}
listEl[1](el)}> 0}>
{(thread) => { const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id) return ( <> 0} expanded={expanded()} onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)} /> 0}> {(child, index) => ( )} ) }}
) } export default SessionList