Files
CodeNomad/packages/ui/src/components/session-list.tsx
Shantur Rathore 1081bfb276 fix(ui): keep session view after delete
When deleting the active session from the sidebar list, automatically select a nearby visible session instead of falling back to the info view.
2026-01-09 18:53:00 +00:00

422 lines
15 KiB
TypeScript

import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { Session, 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,
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
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<SessionListProps> = (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 = props.sessions.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 = props.sessions.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 = () => props.sessions.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 pendingPermission = () => Boolean(session()?.pendingPermission)
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
return (
<div class="session-list-item group">
<button
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
data-session-id={rowProps.sessionId}
onClick={() => selectSession(rowProps.sessionId)}
title={title()}
role="button"
aria-selected={isActive()}
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
>
<div class="session-item-row session-item-header">
<div class="session-item-title-row">
{rowProps.isChild ? (
<Bot class="w-4 h-4 flex-shrink-0" />
) : (
<User class="w-4 h-4 flex-shrink-0" />
)}
<span class="session-item-title session-item-title--clamp">{title()}</span>
</div>
</div>
<div class="session-item-row session-item-meta">
<div class="flex items-center gap-2 min-w-0">
<Show
when={rowProps.hasChildren && !rowProps.isChild}
fallback={
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
}
>
<span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => {
event.stopPropagation()
rowProps.onToggleExpand?.()
}}
role="button"
tabIndex={0}
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
title={rowProps.expanded ? "Collapse" : "Expand"}
>
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{pendingPermission() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{statusText()}
</span>
</div>
<div class="session-item-actions">
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => copySessionId(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label="Copy session ID"
title="Copy session ID"
>
<Copy class="w-3 h-3" />
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => {
event.stopPropagation()
openRenameDialog(rowProps.sessionId)
}}
role="button"
tabIndex={0}
aria-label="Rename session"
title="Rename session"
>
<Pencil class="w-3 h-3" />
</span>
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label="Delete session"
title="Delete session"
>
<Show
when={!isSessionDeleting(rowProps.sessionId)}
fallback={
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
>
<Trash2 class="w-3 h-3" />
</Show>
</span>
</div>
</div>
</button>
</div>
)
}
const activeParentId = createMemo(() => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null
const activeSession = props.sessions.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<HTMLElement | null>(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 (
<div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
>
<Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>
</div>
)}
</div>
</Show>
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<Show when={props.threads.length > 0}>
<div class="session-section">
<For each={props.threads}>
{(thread) => {
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
</For>
</div>
</Show>
</div>
<Show when={props.showFooter !== false}>
<div class="session-list-footer p-3 border-t border-base">
{props.footerContent ?? null}
</div>
</Show>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div>
)
}
export default SessionList