diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 308cb81c..cd1e3d66 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -911,6 +911,7 @@ const InstanceShell2: Component = (props) => { void result.catch((error) => log.error("Failed to create session:", error)) } }} + enableFilterBar showHeader={false} showFooter={false} /> diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 841f50b5..2a8119a9 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -2,12 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl 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 { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare } 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, @@ -35,6 +36,7 @@ interface SessionListProps { showFooter?: boolean headerContent?: JSX.Element footerContent?: JSX.Element + enableFilterBar?: boolean } function formatSessionStatus(status: SessionStatus): string { @@ -46,6 +48,70 @@ const SessionList: Component = (props) => { const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [isRenaming, setIsRenaming] = createSignal(false) + const [filterQuery, setFilterQuery] = createSignal("") + const normalizedQuery = createMemo(() => 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 @@ -82,6 +148,17 @@ const SessionList: Component = (props) => { 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 @@ -152,6 +229,115 @@ const SessionList: Component = (props) => { 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<{ @@ -190,9 +376,31 @@ const SessionList: Component = (props) => { ? t("sessionList.status.needsInput") : statusLabel() - return ( -
+ 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 ( +
+
+ + 0}> +
+ + +
+
+
+ +
{props.headerContent ?? ( @@ -378,33 +651,33 @@ const SessionList: Component = (props) => {
-
listEl[1](el)}> +
listEl[1](el)}> - 0}> -
- + 0}> +
+ - {(thread) => { - const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id) - return ( - <> - 0} - expanded={expanded()} - onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)} - /> + {(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) => ( - - )} - - - - ) - }} + 0}> + + {(child, index) => ( + + )} + + + + ) + }}
diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index 784c411e..0cba5509 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "Session ID copied", "sessionList.copyId.error": "Unable to copy session ID", "sessionList.delete.error": "Unable to delete session", + "sessionList.delete.title": "Delete session", + "sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.", + "sessionList.delete.confirmLabel": "Delete", + "sessionList.delete.cancelLabel": "Cancel", "sessionList.rename.error": "Unable to rename session", + "sessionList.filter.placeholder": "Search sessions…", + "sessionList.filter.ariaLabel": "Search sessions", + "sessionList.selection.selectAllLabel": "Select all", + "sessionList.selection.selectAllAriaLabel": "Select all sessions", + "sessionList.selection.clearLabel": "Clear", + "sessionList.selection.clearAriaLabel": "Clear selection", + "sessionList.selection.checkboxAriaLabel": "Select session", + "sessionList.bulkDelete.button": "Delete {count}", + "sessionList.bulkDelete.ariaLabel": "Delete {count} selected sessions", + "sessionList.bulkDelete.title": "Delete sessions", + "sessionList.bulkDelete.confirmMessage": "Delete {count} selected sessions? This cannot be undone.", + "sessionList.bulkDelete.confirmLabel": "Delete", + "sessionList.bulkDelete.cancelLabel": "Cancel", + "sessionList.bulkDelete.error": "Unable to delete {count} sessions", + "sessionRenameDialog.title": "Rename Session", "sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".", "sessionRenameDialog.description.default": "Set a new title for this session.", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index f4ab8ff7..ecdac0d7 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "ID de sesión copiado", "sessionList.copyId.error": "No se pudo copiar el ID de sesión", "sessionList.delete.error": "No se pudo eliminar la sesión", + "sessionList.delete.title": "Eliminar sesión", + "sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.", + "sessionList.delete.confirmLabel": "Eliminar", + "sessionList.delete.cancelLabel": "Cancelar", "sessionList.rename.error": "No se pudo renombrar la sesión", + "sessionList.filter.placeholder": "Buscar sesiones…", + "sessionList.filter.ariaLabel": "Buscar sesiones", + "sessionList.selection.selectAllLabel": "Seleccionar todo", + "sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones", + "sessionList.selection.clearLabel": "Limpiar", + "sessionList.selection.clearAriaLabel": "Limpiar selección", + "sessionList.selection.checkboxAriaLabel": "Seleccionar sesión", + "sessionList.bulkDelete.button": "Eliminar {count}", + "sessionList.bulkDelete.ariaLabel": "Eliminar {count} sesiones seleccionadas", + "sessionList.bulkDelete.title": "Eliminar sesiones", + "sessionList.bulkDelete.confirmMessage": "¿Eliminar {count} sesiones seleccionadas? Esto no se puede deshacer.", + "sessionList.bulkDelete.confirmLabel": "Eliminar", + "sessionList.bulkDelete.cancelLabel": "Cancelar", + "sessionList.bulkDelete.error": "No se pudieron eliminar {count} sesiones", + "sessionRenameDialog.title": "Renombrar sesión", "sessionRenameDialog.description.withLabel": "Actualiza el título de \"{label}\".", "sessionRenameDialog.description.default": "Establece un nuevo título para esta sesión.", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 79b34762..7bee6007 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "ID de session copié", "sessionList.copyId.error": "Impossible de copier l'ID de session", "sessionList.delete.error": "Impossible de supprimer la session", + "sessionList.delete.title": "Supprimer la session", + "sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.", + "sessionList.delete.confirmLabel": "Supprimer", + "sessionList.delete.cancelLabel": "Annuler", "sessionList.rename.error": "Impossible de renommer la session", + "sessionList.filter.placeholder": "Rechercher des sessions…", + "sessionList.filter.ariaLabel": "Rechercher des sessions", + "sessionList.selection.selectAllLabel": "Tout sélectionner", + "sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions", + "sessionList.selection.clearLabel": "Effacer", + "sessionList.selection.clearAriaLabel": "Effacer la sélection", + "sessionList.selection.checkboxAriaLabel": "Sélectionner la session", + "sessionList.bulkDelete.button": "Supprimer {count}", + "sessionList.bulkDelete.ariaLabel": "Supprimer {count} sessions sélectionnées", + "sessionList.bulkDelete.title": "Supprimer des sessions", + "sessionList.bulkDelete.confirmMessage": "Supprimer {count} sessions sélectionnées ? Cette action est irréversible.", + "sessionList.bulkDelete.confirmLabel": "Supprimer", + "sessionList.bulkDelete.cancelLabel": "Annuler", + "sessionList.bulkDelete.error": "Impossible de supprimer {count} sessions", + "sessionRenameDialog.title": "Renommer la session", "sessionRenameDialog.description.withLabel": "Mettre à jour le titre de \"{label}\".", "sessionRenameDialog.description.default": "Définir un nouveau titre pour cette session.", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index 20342942..ca5e2d09 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "セッション ID をコピーしました", "sessionList.copyId.error": "セッション ID をコピーできません", "sessionList.delete.error": "セッションを削除できません", + "sessionList.delete.title": "セッションを削除", + "sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。", + "sessionList.delete.confirmLabel": "削除", + "sessionList.delete.cancelLabel": "キャンセル", "sessionList.rename.error": "セッション名を変更できません", + "sessionList.filter.placeholder": "セッションを検索…", + "sessionList.filter.ariaLabel": "セッションを検索", + "sessionList.selection.selectAllLabel": "すべて選択", + "sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択", + "sessionList.selection.clearLabel": "クリア", + "sessionList.selection.clearAriaLabel": "選択をクリア", + "sessionList.selection.checkboxAriaLabel": "セッションを選択", + "sessionList.bulkDelete.button": "{count} 件を削除", + "sessionList.bulkDelete.ariaLabel": "選択した {count} 件のセッションを削除", + "sessionList.bulkDelete.title": "セッションを削除", + "sessionList.bulkDelete.confirmMessage": "選択した {count} 件のセッションを削除しますか?この操作は元に戻せません。", + "sessionList.bulkDelete.confirmLabel": "削除", + "sessionList.bulkDelete.cancelLabel": "キャンセル", + "sessionList.bulkDelete.error": "{count} 件のセッションを削除できません", + "sessionRenameDialog.title": "セッション名を変更", "sessionRenameDialog.description.withLabel": "\"{label}\" のタイトルを更新します。", "sessionRenameDialog.description.default": "このセッションの新しいタイトルを設定します。", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index eddb136c..f15194e2 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "ID сессии скопирован", "sessionList.copyId.error": "Не удалось скопировать ID сессии", "sessionList.delete.error": "Не удалось удалить сессию", + "sessionList.delete.title": "Удалить сессию", + "sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.", + "sessionList.delete.confirmLabel": "Удалить", + "sessionList.delete.cancelLabel": "Отмена", "sessionList.rename.error": "Не удалось переименовать сессию", + "sessionList.filter.placeholder": "Поиск сессий…", + "sessionList.filter.ariaLabel": "Поиск сессий", + "sessionList.selection.selectAllLabel": "Выбрать все", + "sessionList.selection.selectAllAriaLabel": "Выбрать все сессии", + "sessionList.selection.clearLabel": "Очистить", + "sessionList.selection.clearAriaLabel": "Очистить выбор", + "sessionList.selection.checkboxAriaLabel": "Выбрать сессию", + "sessionList.bulkDelete.button": "Удалить {count}", + "sessionList.bulkDelete.ariaLabel": "Удалить {count} выбранных сессий", + "sessionList.bulkDelete.title": "Удалить сессии", + "sessionList.bulkDelete.confirmMessage": "Удалить {count} выбранных сессий? Это действие нельзя отменить.", + "sessionList.bulkDelete.confirmLabel": "Удалить", + "sessionList.bulkDelete.cancelLabel": "Отмена", + "sessionList.bulkDelete.error": "Не удалось удалить {count} сессий", + "sessionRenameDialog.title": "Переименовать сессию", "sessionRenameDialog.description.withLabel": "Обновите название для \"{label}\".", "sessionRenameDialog.description.default": "Установите новое название для этой сессии.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index effac4f2..3dfc79a8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -30,8 +30,27 @@ export const sessionMessages = { "sessionList.copyId.success": "已复制会话 ID", "sessionList.copyId.error": "无法复制会话 ID", "sessionList.delete.error": "无法删除会话", + "sessionList.delete.title": "删除会话", + "sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。", + "sessionList.delete.confirmLabel": "删除", + "sessionList.delete.cancelLabel": "取消", "sessionList.rename.error": "无法重命名会话", + "sessionList.filter.placeholder": "搜索会话…", + "sessionList.filter.ariaLabel": "搜索会话", + "sessionList.selection.selectAllLabel": "全选", + "sessionList.selection.selectAllAriaLabel": "选择所有会话", + "sessionList.selection.clearLabel": "清除", + "sessionList.selection.clearAriaLabel": "清除选择", + "sessionList.selection.checkboxAriaLabel": "选择会话", + "sessionList.bulkDelete.button": "删除 {count}", + "sessionList.bulkDelete.ariaLabel": "删除已选择的 {count} 个会话", + "sessionList.bulkDelete.title": "删除会话", + "sessionList.bulkDelete.confirmMessage": "删除已选择的 {count} 个会话?此操作无法撤销。", + "sessionList.bulkDelete.confirmLabel": "删除", + "sessionList.bulkDelete.cancelLabel": "取消", + "sessionList.bulkDelete.error": "无法删除 {count} 个会话", + "sessionRenameDialog.title": "重命名会话", "sessionRenameDialog.description.withLabel": "更新“{label}”的标题。", "sessionRenameDialog.description.default": "为此会话设置新标题。",