## What and why CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew or Arabic would see their messages displayed left-to-right — misaligned text, broken reading flow, wrong punctuation placement. This PR adds automatic direction detection to all elements that display user or model text. The browser detects direction from the first strong character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No configuration needed — it just works per message, per paragraph. ## Technical notes The natural fix is `dir="auto"` on the containing elements. However, Chromium does not propagate direction detection from a parent `<div>` into its `<p>` children — so Hebrew inside `<p>` rendered via `innerHTML` (as markdown is) was still detected as LTR. The fix is to apply `unicode-bidi: plaintext` via CSS directly on the block-level elements (`p`, `li`, headings, etc.), which has the same auto-detection semantics but applies per element. ## Summary - Add `dir="auto"` to all elements containing user-generated or model-generated text (message content, prompt input, session names, tool outputs) so the browser auto-detects text direction - Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`, `li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL detection in Chromium (where `dir="auto"` on a parent div does not recurse into block children) - Convert physical CSS properties to logical equivalents in `markdown.css`: `border-left` → `border-inline-start`, `padding-left` → `padding-inline-start`, `text-align: left` → `text-align: start`, `margin-left` → `margin-inline-start` ## Affected components - `markdown.tsx` — main markdown renderer - `message-part.tsx` — text part wrapper and plain-text fallback - `message-item.tsx` — message body and error blocks - `prompt-input.tsx` — user input textarea - `session-list.tsx` — session titles in sidebar - `session-rename-dialog.tsx` — session rename input - `instance-welcome-view.tsx` — Resume Session dialog - `tool-call/markdown-render.tsx` — tool output markdown fallback - `tool-call/ansi-render.tsx` — ANSI output - `tool-call/diagnostics-section.tsx` — diagnostic messages ## Test plan - [ ] Send a Hebrew-only message → text right-aligned - [ ] Send a mixed Hebrew + English message → correct per-paragraph direction - [ ] Message containing a code block → code stays LTR - [ ] Type Hebrew in the prompt textarea → input flows right-to-left - [ ] Hebrew session name in sidebar → right-aligned - [ ] Hebrew session name in Resume Session dialog → right-aligned 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
733 lines
26 KiB
TypeScript
733 lines
26 KiB
TypeScript
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<SessionListProps> = (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<Set<string>>(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<SessionThread[]>(() => {
|
|
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<string[]>(() => {
|
|
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<string>())
|
|
|
|
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 (
|
|
<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">
|
|
<Show when={props.enableFilterBar}>
|
|
<input
|
|
ref={(el) => {
|
|
rowCheckboxEl = el
|
|
}}
|
|
type="checkbox"
|
|
checked={parentGroupState().checked}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onChange={(event) => {
|
|
event.stopPropagation()
|
|
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
|
|
}}
|
|
aria-label={t("sessionList.selection.checkboxAriaLabel")}
|
|
/>
|
|
</Show>
|
|
|
|
{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" dir="auto">{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 ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
|
|
}
|
|
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
|
|
>
|
|
<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()}`}>
|
|
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
|
{statusText()}
|
|
</span>
|
|
<Show when={showWorktreeBadge()}>
|
|
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
|
|
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
|
<span class="worktree-indicator-label">{worktreeSlug()}</span>
|
|
</span>
|
|
</Show>
|
|
</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={t("sessionList.actions.copyId.ariaLabel")}
|
|
title={t("sessionList.actions.copyId.title")}
|
|
>
|
|
<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={t("sessionList.actions.rename.ariaLabel")}
|
|
title={t("sessionList.actions.rename.title")}
|
|
>
|
|
<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={t("sessionList.actions.delete.ariaLabel")}
|
|
title={t("sessionList.actions.delete.title")}
|
|
>
|
|
<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 = 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<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.enableFilterBar}>
|
|
<div class="p-3 border-b border-base">
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative flex-1 min-w-0">
|
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
|
|
<Search class="w-4 h-4" />
|
|
</span>
|
|
<input
|
|
type="text"
|
|
class="form-input pl-9"
|
|
value={filterQuery()}
|
|
onInput={(e) => setFilterQuery(e.currentTarget.value)}
|
|
placeholder={t("sessionList.filter.placeholder")}
|
|
aria-label={t("sessionList.filter.ariaLabel")}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="button-tertiary p-2 inline-flex items-center justify-center"
|
|
onClick={toggleSelectAll}
|
|
disabled={allMatchingSessionIds().length === 0}
|
|
aria-label={t("sessionList.selection.selectAllAriaLabel")}
|
|
title={t("sessionList.selection.selectAllLabel")}
|
|
>
|
|
<Show
|
|
when={isSelectAllIndeterminate()}
|
|
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
|
|
>
|
|
<MinusSquare class="w-4 h-4" />
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
|
|
<Show when={selectedCount() > 0}>
|
|
<div class="mt-2 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
class="button-tertiary"
|
|
onClick={handleBulkDelete}
|
|
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
|
|
>
|
|
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="button-tertiary"
|
|
onClick={() => setSelectedSessionIds(new Set<string>())}
|
|
aria-label={t("sessionList.selection.clearAriaLabel")}
|
|
>
|
|
{t("sessionList.selection.clearLabel")}
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
|
|
<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">{t("sessionList.header.title")}</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={filteredThreads().length > 0}>
|
|
<div class="session-section">
|
|
<For each={filteredThreads()}>
|
|
|
|
{(thread) => {
|
|
const expanded = () => (normalizedQuery() ? true : 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
|