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, Search, Square, CheckSquare, MinusSquare, Split } 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 { useI18n } from "../lib/i18n" import { showConfirmDialog } from "../stores/alerts" import { deleteSession, ensureSessionParentExpanded, getVisibleSessionIds, isSessionParentExpanded, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, toggleSessionParentExpanded, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" 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 enableFilterBar?: boolean } function formatSessionStatus(status: SessionStatus): string { return status } const SessionList: Component = (props) => { const { t } = useI18n() const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) const [filterQuery, setFilterQuery] = createSignal("") const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) const normalizeSessionLabel = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const title = (session?.title ?? "").trim() return title || t("sessionList.session.untitled") } const sessionMatchesQuery = (sessionId: string, query: string) => { if (!query) return true const label = normalizeSessionLabel(sessionId).toLowerCase() if (label.includes(query)) return true return sessionId.toLowerCase().includes(query) } const filteredThreads = createMemo(() => { const query = normalizedQuery() if (!query) return props.threads const next: SessionThread[] = [] for (const thread of props.threads) { const parentMatches = sessionMatchesQuery(thread.parent.id, query) const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query)) if (!parentMatches && matchingChildren.length === 0) continue next.push({ parent: thread.parent, children: matchingChildren, latestUpdated: thread.latestUpdated, }) } return next }) const allMatchingSessionIds = createMemo(() => { const ids: string[] = [] for (const thread of filteredThreads()) { ids.push(thread.parent.id) for (const child of thread.children) ids.push(child.id) } return ids }) const selectedCount = createMemo(() => selectedSessionIds().size) const isAllSelected = createMemo(() => { const ids = allMatchingSessionIds() if (ids.length === 0) return false const selected = selectedSessionIds() return ids.every((id) => selected.has(id)) }) const isSelectAllIndeterminate = createMemo(() => { const ids = allMatchingSessionIds() const total = ids.length if (total === 0) return false const count = selectedCount() return count > 0 && count < total }) 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) // If the user selects a child session, make sure its parent thread is expanded. // For parent sessions we don't force expansion; user can collapse/expand freely. if (session?.parentId) { ensureSessionParentExpanded(props.instanceId, session.parentId) } props.onSelect(sessionId) } const copySessionId = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() try { const success = await copyToClipboard(sessionId) if (success) { showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" }) } else { showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" }) } } catch (error) { log.error(`Failed to copy session ID ${sessionId}:`, error) showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" }) } } const handleDeleteSession = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() if (isSessionDeleting(sessionId)) return const confirmed = await showConfirmDialog( t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }), { title: t("sessionList.delete.title"), variant: "warning", confirmLabel: t("sessionList.delete.confirmLabel"), cancelLabel: t("sessionList.delete.cancelLabel"), }, ) if (!confirmed) 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: t("sessionList.delete.error"), 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: t("sessionList.rename.error"), variant: "error" }) } finally { setIsRenaming(false) } } const setSelectedMany = (sessionIds: string[], checked: boolean) => { if (sessionIds.length === 0) return setSelectedSessionIds((prev) => { const next = new Set(prev) sessionIds.forEach((id) => { if (checked) next.add(id) else next.delete(id) }) return next }) } const getSelectableThreadIds = (parentId: string): string[] => { const query = normalizedQuery() const source = query ? filteredThreads() : props.threads const thread = source.find((t) => t.parent.id === parentId) if (!thread) return [parentId] return [thread.parent.id, ...thread.children.map((c) => c.id)] } const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => { const ids: string[] = [] threads.forEach((thread) => { ids.push(thread.parent.id) thread.children.forEach((child) => ids.push(child.id)) }) return ids } const handleToggleSelectAll = (checked: boolean) => { const ids = allMatchingSessionIds() setSelectedMany(ids, checked) } const toggleSelectAll = () => { if (isAllSelected()) { handleToggleSelectAll(false) return } handleToggleSelectAll(true) } const handleBulkDelete = async () => { const selected = Array.from(selectedSessionIds()) if (selected.length === 0) return const confirmed = await showConfirmDialog( t("sessionList.bulkDelete.confirmMessage", { count: selected.length }), { title: t("sessionList.bulkDelete.title"), variant: "warning", confirmLabel: t("sessionList.bulkDelete.confirmLabel"), cancelLabel: t("sessionList.bulkDelete.cancelLabel"), }, ) if (!confirmed) return const deletedSet = new Set(selected) const currentActiveId = props.activeSessionId let fallbackSessionId: string | undefined if (currentActiveId && deletedSet.has(currentActiveId)) { const ordered = getAllSessionIdsInOrder(props.threads) const currentIndex = ordered.indexOf(currentActiveId) for (let i = Math.max(0, currentIndex); i < ordered.length; i++) { const candidate = ordered[i] if (candidate && !deletedSet.has(candidate)) { fallbackSessionId = candidate break } } if (!fallbackSessionId) { for (let i = currentIndex - 1; i >= 0; i--) { const candidate = ordered[i] if (candidate && !deletedSet.has(candidate)) { fallbackSessionId = candidate break } } } } let failed = 0 for (const sessionId of selected) { try { // eslint-disable-next-line no-await-in-loop await deleteSession(props.instanceId, sessionId) } catch (error) { failed += 1 log.error(`Failed to delete session ${sessionId}:`, error) } } setSelectedSessionIds(new Set()) if (fallbackSessionId) { setActiveSessionFromList(props.instanceId, fallbackSessionId) } if (failed > 0) { showToastNotification({ message: t("sessionList.bulkDelete.error", { count: failed }), variant: "error", }) } } 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 worktreeSlug = createMemo(() => { if (rowProps.isChild) return "root" return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId) }) const showWorktreeBadge = createMemo(() => { if (rowProps.isChild) return false if (getGitRepoStatus(props.instanceId) === false) return false const slug = worktreeSlug() return Boolean(slug) && slug !== "root" }) const isActive = () => props.activeSessionId === rowProps.sessionId const title = () => session()?.title || t("sessionList.session.untitled") const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const statusLabel = () => { switch (formatSessionStatus(status())) { case "working": return t("sessionList.status.working") case "compacting": return t("sessionList.status.compacting") default: return t("sessionList.status.idle") } } 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() ? t("sessionList.status.needsPermission") : needsQuestion() ? t("sessionList.status.needsInput") : statusLabel() const isSelected = () => selectedSessionIds().has(rowProps.sessionId) const parentGroupState = createMemo(() => { if (rowProps.isChild) { return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] } } const ids = getSelectableThreadIds(rowProps.sessionId) const selected = selectedSessionIds() const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0) return { checked: selectedInGroup > 0 && selectedInGroup === ids.length, indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length, ids, } }) let rowCheckboxEl: HTMLInputElement | null = null createEffect(() => { if (!rowCheckboxEl) return rowCheckboxEl.indeterminate = parentGroupState().indeterminate }) 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(() => { // Keep the active child session visible by ensuring its parent is expanded. // Don't force-expanding when the active session itself is a parent lets users collapse it. const activeId = props.activeSessionId if (!activeId || activeId === "info") return const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId) if (!activeSession) return if (!activeSession.parentId) return 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 (
setFilterQuery(e.currentTarget.value)} placeholder={t("sessionList.filter.placeholder")} aria-label={t("sessionList.filter.ariaLabel")} />
0}>
{props.headerContent ?? (

{t("sessionList.header.title")}

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