mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
feat(ui): add multi-session selection and deletion (#51924)
* feat(sessions): implement multi-session deletion and selection functionality - Added `deleteSessionsAndRefresh` function to handle deletion of multiple sessions. - Updated session state management to track selected session keys. - Enhanced UI to support bulk actions for selected sessions, including delete and unselect options. - Refactored related tests to accommodate new multi-session deletion logic. - Improved responsiveness of sessions table with new CSS rules for mobile layouts. * feat(sessions): add page deselection functionality and enhance error handling - Implemented `onDeselectPage` method to allow deselecting specific pages in the session view. - Updated `deleteSessionsAndRefresh` to handle multiple deletion errors, storing them in an array and displaying a consolidated error message. - Enhanced tests to verify the new deselection behavior and updated error handling for session deletions.
This commit is contained in:
@@ -1820,22 +1820,21 @@
|
||||
}
|
||||
|
||||
.data-table-container {
|
||||
overflow-x: auto;
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
.data-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
z-index: 3;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
@@ -1937,6 +1936,80 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Checkbox column */
|
||||
.data-table-checkbox-col {
|
||||
width: 1%;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 4px !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.data-table th.data-table-checkbox-col {
|
||||
z-index: 4;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
td.data-table-checkbox-col {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td.data-table-checkbox-col {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.data-table-checkbox-col input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Sticky Key column (second column, after checkbox) */
|
||||
.data-table .data-table-key-col {
|
||||
position: sticky;
|
||||
left: 36px;
|
||||
z-index: 2;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.data-table th.data-table-key-col {
|
||||
z-index: 4;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
td.data-table-key-col {
|
||||
background: var(--card);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td.data-table-key-col {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Bulk action bar */
|
||||
.data-table-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--accent-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.data-table-bulk-bar .btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.data-table-pagination {
|
||||
display: flex;
|
||||
@@ -1979,93 +2052,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Row actions */
|
||||
.data-table-row-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data-table-row-actions__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.data-table-row-actions__trigger svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.data-table-row-actions__trigger:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.data-table-row-actions__menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 42;
|
||||
min-width: 140px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 4px;
|
||||
animation: fade-in var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.data-table-row-actions__menu a,
|
||||
.data-table-row-actions__menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.data-table-row-actions__menu a:hover,
|
||||
.data-table-row-actions__menu button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.data-table-row-actions__menu button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.data-table-row-actions__menu button.danger:hover {
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
/* Click-away overlay for open menus */
|
||||
.data-table-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Inline form fields for filter bars */
|
||||
.field-inline {
|
||||
display: inline-flex;
|
||||
@@ -4074,6 +4060,29 @@ details[open] > .ov-expandable-toggle::after {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared collapse chevron ── */
|
||||
|
||||
.collapse-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.collapse-chevron svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.collapse-chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ov-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -651,3 +651,113 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Sessions table – responsive column hiding
|
||||
=========================================== */
|
||||
|
||||
/* nth-child is 1-based and includes the checkbox column (col 1). */
|
||||
|
||||
/* ≤1400px: hide Verbose (col 9) and Reasoning (col 10) */
|
||||
@media (max-width: 1400px) {
|
||||
.data-table th:nth-child(9),
|
||||
.data-table td:nth-child(9),
|
||||
.data-table th:nth-child(10),
|
||||
.data-table td:nth-child(10) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤1100px: also hide Fast (col 8) and Kind (col 4) */
|
||||
@media (max-width: 1100px) {
|
||||
.data-table th:nth-child(4),
|
||||
.data-table td:nth-child(4),
|
||||
.data-table th:nth-child(8),
|
||||
.data-table td:nth-child(8) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤900px: also hide Thinking (col 7) and Label (col 3) */
|
||||
@media (max-width: 900px) {
|
||||
.data-table th:nth-child(3),
|
||||
.data-table td:nth-child(3),
|
||||
.data-table th:nth-child(7),
|
||||
.data-table td:nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤768px: also hide Tokens (col 6); tighten padding; stack pagination */
|
||||
@media (max-width: 768px) {
|
||||
.data-table th:nth-child(6),
|
||||
.data-table td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table-pagination {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.data-table-pagination__info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-table-pagination__controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.data-table-search input {
|
||||
font-size: 14px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.data-table-wrapper {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.session-key-cell {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.session-key-cell .session-link,
|
||||
.session-key-cell .session-key-display-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≤500px: tighten further, truncate key column */
|
||||
@media (max-width: 500px) {
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 6px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.session-key-cell {
|
||||
max-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { deleteSessionAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||
import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||
import {
|
||||
installSkill,
|
||||
loadSkills,
|
||||
@@ -477,7 +477,7 @@ export function renderApp(state: AppViewState) {
|
||||
title="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
aria-label="${navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
>
|
||||
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
|
||||
<span class="nav-collapse-toggle__icon" aria-hidden="true">${navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-shell__body">
|
||||
@@ -506,7 +506,7 @@ export function renderApp(state: AppViewState) {
|
||||
>
|
||||
<span class="nav-section__label-text">${t(`nav.${group.label}`)}</span>
|
||||
<span class="nav-section__chevron">
|
||||
${showItems ? icons.chevronDown : icons.chevronRight}
|
||||
${icons.chevronDown}
|
||||
</span>
|
||||
</button>
|
||||
`
|
||||
@@ -737,7 +737,7 @@ export function renderApp(state: AppViewState) {
|
||||
sortDir: state.sessionsSortDir,
|
||||
page: state.sessionsPage,
|
||||
pageSize: state.sessionsPageSize,
|
||||
actionsOpenKey: state.sessionsActionsOpenKey,
|
||||
selectedKeys: state.sessionsSelectedKeys,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
@@ -760,12 +760,45 @@ export function renderApp(state: AppViewState) {
|
||||
state.sessionsPageSize = s;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onActionsOpenChange: (key) => {
|
||||
state.sessionsActionsOpenKey = key;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSessionAndRefresh(state, key),
|
||||
onToggleSelect: (key) => {
|
||||
const next = new Set(state.sessionsSelectedKeys);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
state.sessionsSelectedKeys = next;
|
||||
},
|
||||
onSelectPage: (keys) => {
|
||||
const next = new Set(state.sessionsSelectedKeys);
|
||||
for (const k of keys) {
|
||||
next.add(k);
|
||||
}
|
||||
state.sessionsSelectedKeys = next;
|
||||
},
|
||||
onDeselectPage: (keys) => {
|
||||
const next = new Set(state.sessionsSelectedKeys);
|
||||
for (const k of keys) {
|
||||
next.delete(k);
|
||||
}
|
||||
state.sessionsSelectedKeys = next;
|
||||
},
|
||||
onDeselectAll: () => {
|
||||
state.sessionsSelectedKeys = new Set();
|
||||
},
|
||||
onDeleteSelected: async () => {
|
||||
const keys = [...state.sessionsSelectedKeys];
|
||||
const deleted = await deleteSessionsAndRefresh(state, keys);
|
||||
if (deleted.length > 0) {
|
||||
const next = new Set(state.sessionsSelectedKeys);
|
||||
for (const k of deleted) {
|
||||
next.delete(k);
|
||||
}
|
||||
state.sessionsSelectedKeys = next;
|
||||
}
|
||||
},
|
||||
onNavigateToChat: (sessionKey) => {
|
||||
switchChatSession(state, sessionKey);
|
||||
state.setTab("chat" as import("./navigation.ts").Tab);
|
||||
|
||||
@@ -191,7 +191,7 @@ export type AppViewState = {
|
||||
sessionsSortDir: "asc" | "desc";
|
||||
sessionsPage: number;
|
||||
sessionsPageSize: number;
|
||||
sessionsActionsOpenKey: string | null;
|
||||
sessionsSelectedKeys: Set<string>;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
|
||||
@@ -288,8 +288,8 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
|
||||
@state() sessionsSortDir: "asc" | "desc" = "desc";
|
||||
@state() sessionsPage = 0;
|
||||
@state() sessionsPageSize = 10;
|
||||
@state() sessionsActionsOpenKey: string | null = null;
|
||||
@state() sessionsPageSize = 25;
|
||||
@state() sessionsSelectedKeys: Set<string> = new Set();
|
||||
|
||||
@state() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deleteSession,
|
||||
deleteSessionAndRefresh,
|
||||
subscribeSessions,
|
||||
type SessionsState,
|
||||
} from "./sessions.ts";
|
||||
import { deleteSessionsAndRefresh, subscribeSessions, type SessionsState } from "./sessions.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
@@ -47,8 +42,8 @@ describe("subscribeSessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSessionAndRefresh", () => {
|
||||
it("refreshes sessions after a successful delete", async () => {
|
||||
describe("deleteSessionsAndRefresh", () => {
|
||||
it("deletes multiple sessions and refreshes", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
@@ -61,39 +56,44 @@ describe("deleteSessionAndRefresh", () => {
|
||||
const state = createState(request);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
const deleted = await deleteSessionsAndRefresh(state, ["key-a", "key-b"]);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(deleted).toEqual(["key-a", "key-b"]);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", {
|
||||
key: "agent:main:test",
|
||||
key: "key-a",
|
||||
deleteTranscript: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sessions.delete", {
|
||||
key: "key-b",
|
||||
deleteTranscript: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
expect(state.sessionsError).toBeNull();
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not refresh sessions when user cancels delete", async () => {
|
||||
it("returns empty array when user cancels", async () => {
|
||||
const request = vi.fn(async () => undefined);
|
||||
const state = createState(request, { sessionsError: "existing error" });
|
||||
const state = createState(request);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
const deleted = await deleteSessionsAndRefresh(state, ["key-a"]);
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(deleted).toEqual([]);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.sessionsError).toBe("existing error");
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not refresh sessions when delete fails and preserves the delete error", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
it("returns partial results when some deletes fail", async () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "sessions.delete") {
|
||||
throw new Error("delete boom");
|
||||
const p = params as { key: string };
|
||||
if (p.key === "key-b" || p.key === "key-c") {
|
||||
throw new Error(`delete failed: ${p.key}`);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return undefined;
|
||||
@@ -103,27 +103,20 @@ describe("deleteSessionAndRefresh", () => {
|
||||
const state = createState(request);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
const deleted = await deleteSessionsAndRefresh(state, ["key-a", "key-b", "key-c", "key-d"]);
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("sessions.delete", {
|
||||
key: "agent:main:test",
|
||||
deleteTranscript: true,
|
||||
});
|
||||
expect(state.sessionsError).toContain("delete boom");
|
||||
expect(deleted).toEqual(["key-a", "key-d"]);
|
||||
expect(state.sessionsError).toBe("Error: delete failed: key-b; Error: delete failed: key-c");
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSession", () => {
|
||||
it("returns false when already loading", async () => {
|
||||
it("returns empty array when already loading", async () => {
|
||||
const request = vi.fn(async () => undefined);
|
||||
const state = createState(request, { sessionsLoading: true });
|
||||
|
||||
const deleted = await deleteSession(state, "agent:main:test");
|
||||
const deleted = await deleteSessionsAndRefresh(state, ["key-a"]);
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(deleted).toEqual([]);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,37 +106,44 @@ export async function patchSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(state: SessionsState, key: string): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
export async function deleteSessionsAndRefresh(
|
||||
state: SessionsState,
|
||||
keys: string[],
|
||||
): Promise<string[]> {
|
||||
if (!state.client || !state.connected || keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (state.sessionsLoading) {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
const noun = keys.length === 1 ? "session" : "sessions";
|
||||
const confirmed = window.confirm(
|
||||
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
|
||||
`Delete ${keys.length} ${noun}?\n\nThis will delete the session entries and archive their transcripts.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
const deleted: string[] = [];
|
||||
const deleteErrors: string[] = [];
|
||||
try {
|
||||
await state.client.request("sessions.delete", { key, deleteTranscript: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
return false;
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await state.client.request("sessions.delete", { key, deleteTranscript: true });
|
||||
deleted.push(key);
|
||||
} catch (err) {
|
||||
deleteErrors.push(String(err));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSessionAndRefresh(state: SessionsState, key: string): Promise<boolean> {
|
||||
const deleted = await deleteSession(state, key);
|
||||
if (!deleted) {
|
||||
return false;
|
||||
if (deleted.length > 0) {
|
||||
await loadSessions(state);
|
||||
}
|
||||
await loadSessions(state);
|
||||
return true;
|
||||
if (deleteErrors.length > 0) {
|
||||
state.sessionsError = deleteErrors.join("; ");
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ function renderPinnedSection(
|
||||
}}>
|
||||
${icons.bookmark}
|
||||
${entries.length} pinned
|
||||
${vs.pinnedExpanded ? icons.chevronDown : icons.chevronRight}
|
||||
<span class="collapse-chevron ${vs.pinnedExpanded ? "" : "collapse-chevron--collapsed"}">${icons.chevronDown}</span>
|
||||
</button>
|
||||
${
|
||||
vs.pinnedExpanded
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import { renderSessions, type SessionsProps } from "./sessions.ts";
|
||||
|
||||
@@ -13,6 +15,16 @@ function buildResult(session: SessionsListResult["sessions"][number]): SessionsL
|
||||
};
|
||||
}
|
||||
|
||||
function buildMultiResult(sessions: SessionsListResult["sessions"]): SessionsListResult {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "(multiple)",
|
||||
count: sessions.length,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProps(result: SessionsListResult): SessionsProps {
|
||||
return {
|
||||
loading: false,
|
||||
@@ -28,16 +40,19 @@ function buildProps(result: SessionsListResult): SessionsProps {
|
||||
sortDir: "desc",
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
actionsOpenKey: null,
|
||||
selectedKeys: new Set<string>(),
|
||||
onFiltersChange: () => undefined,
|
||||
onSearchChange: () => undefined,
|
||||
onSortChange: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onPageSizeChange: () => undefined,
|
||||
onActionsOpenChange: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onPatch: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
onToggleSelect: () => undefined,
|
||||
onSelectPage: () => undefined,
|
||||
onDeselectPage: () => undefined,
|
||||
onDeselectAll: () => undefined,
|
||||
onDeleteSelected: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,4 +126,43 @@ describe("sessions view", () => {
|
||||
const fast = selects[1] as HTMLSelectElement | undefined;
|
||||
expect(fast?.value).toBe("on");
|
||||
});
|
||||
|
||||
it("deselects only the current page from the header checkbox", async () => {
|
||||
const onSelectPage = vi.fn();
|
||||
const onDeselectPage = vi.fn();
|
||||
const onDeselectAll = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderSessions({
|
||||
...buildProps(
|
||||
buildMultiResult([
|
||||
{
|
||||
key: "page-0",
|
||||
kind: "direct",
|
||||
updatedAt: 20,
|
||||
},
|
||||
{
|
||||
key: "page-1",
|
||||
kind: "direct",
|
||||
updatedAt: 10,
|
||||
},
|
||||
]),
|
||||
),
|
||||
pageSize: 1,
|
||||
selectedKeys: new Set(["page-0", "off-page"]),
|
||||
onSelectPage,
|
||||
onDeselectPage,
|
||||
onDeselectAll,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const headerCheckbox = container.querySelector("thead input[type=checkbox]");
|
||||
headerCheckbox?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(onDeselectPage).toHaveBeenCalledWith(["page-0"]);
|
||||
expect(onDeselectAll).not.toHaveBeenCalled();
|
||||
expect(onSelectPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export type SessionsProps = {
|
||||
sortDir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actionsOpenKey: string | null;
|
||||
selectedKeys: Set<string>;
|
||||
onFiltersChange: (next: {
|
||||
activeMinutes: string;
|
||||
limit: string;
|
||||
@@ -30,7 +30,6 @@ export type SessionsProps = {
|
||||
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onActionsOpenChange: (key: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
onPatch: (
|
||||
key: string,
|
||||
@@ -42,7 +41,11 @@ export type SessionsProps = {
|
||||
reasoningLevel?: string | null;
|
||||
},
|
||||
) => void;
|
||||
onDelete: (key: string) => void;
|
||||
onToggleSelect: (key: string) => void;
|
||||
onSelectPage: (keys: string[]) => void;
|
||||
onDeselectPage: (keys: string[]) => void;
|
||||
onDeselectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onNavigateToChat?: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
@@ -187,11 +190,16 @@ export function renderSessions(props: SessionsProps) {
|
||||
const page = Math.min(props.page, totalPages - 1);
|
||||
const paginated = paginateRows(sorted, page, props.pageSize);
|
||||
|
||||
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
|
||||
const sortHeader = (
|
||||
col: "key" | "kind" | "updated" | "tokens",
|
||||
label: string,
|
||||
extraClass = "",
|
||||
) => {
|
||||
const isActive = props.sortColumn === col;
|
||||
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
|
||||
return html`
|
||||
<th
|
||||
class=${extraClass}
|
||||
data-sortable
|
||||
data-sort-dir=${isActive ? props.sortDir : ""}
|
||||
@click=${() => props.onSortChange(col, isActive ? nextDir : "desc")}
|
||||
@@ -203,18 +211,7 @@ export function renderSessions(props: SessionsProps) {
|
||||
};
|
||||
|
||||
return html`
|
||||
${
|
||||
props.actionsOpenKey
|
||||
? html`
|
||||
<div
|
||||
class="data-table-overlay"
|
||||
@click=${() => props.onActionsOpenChange(null)}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<section class="card" style=${props.actionsOpenKey ? "position: relative; z-index: 41;" : ""}>
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="card-title">Sessions</div>
|
||||
@@ -303,11 +300,54 @@ export function renderSessions(props: SessionsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
props.selectedKeys.size > 0
|
||||
? html`
|
||||
<div class="data-table-bulk-bar">
|
||||
<span>${props.selectedKeys.size} selected</span>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@click=${props.onDeselectAll}
|
||||
>
|
||||
Unselect
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
?disabled=${props.loading}
|
||||
@click=${props.onDeleteSelected}
|
||||
>
|
||||
${icons.trash} Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${sortHeader("key", "Key")}
|
||||
<th class="data-table-checkbox-col">
|
||||
${
|
||||
paginated.length > 0
|
||||
? html`<input
|
||||
type="checkbox"
|
||||
.checked=${paginated.length > 0 && paginated.every((r) => props.selectedKeys.has(r.key))}
|
||||
.indeterminate=${paginated.some((r) => props.selectedKeys.has(r.key)) && !paginated.every((r) => props.selectedKeys.has(r.key))}
|
||||
@change=${() => {
|
||||
const allSelected = paginated.every((r) => props.selectedKeys.has(r.key));
|
||||
if (allSelected) {
|
||||
props.onDeselectPage(paginated.map((r) => r.key));
|
||||
} else {
|
||||
props.onSelectPage(paginated.map((r) => r.key));
|
||||
}
|
||||
}}
|
||||
aria-label="Select all on page"
|
||||
/>`
|
||||
: nothing
|
||||
}
|
||||
</th>
|
||||
${sortHeader("key", "Key", "data-table-key-col")}
|
||||
<th>Label</th>
|
||||
${sortHeader("kind", "Kind")}
|
||||
${sortHeader("updated", "Updated")}
|
||||
@@ -316,7 +356,6 @@ export function renderSessions(props: SessionsProps) {
|
||||
<th>Fast</th>
|
||||
<th>Verbose</th>
|
||||
<th>Reasoning</th>
|
||||
<th style="width: 60px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -334,9 +373,8 @@ export function renderSessions(props: SessionsProps) {
|
||||
row,
|
||||
props.basePath,
|
||||
props.onPatch,
|
||||
props.onDelete,
|
||||
props.onActionsOpenChange,
|
||||
props.actionsOpenKey,
|
||||
props.selectedKeys.has(row.key),
|
||||
props.onToggleSelect,
|
||||
props.loading,
|
||||
props.onNavigateToChat,
|
||||
),
|
||||
@@ -389,9 +427,8 @@ function renderRow(
|
||||
row: GatewaySessionRow,
|
||||
basePath: string,
|
||||
onPatch: SessionsProps["onPatch"],
|
||||
onDelete: SessionsProps["onDelete"],
|
||||
onActionsOpenChange: (key: string | null) => void,
|
||||
actionsOpenKey: string | null,
|
||||
selected: boolean,
|
||||
onToggleSelect: SessionsProps["onToggleSelect"],
|
||||
disabled: boolean,
|
||||
onNavigateToChat?: (sessionKey: string) => void,
|
||||
) {
|
||||
@@ -419,7 +456,6 @@ function renderRow(
|
||||
const chatUrl = canLink
|
||||
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
||||
: null;
|
||||
const isMenuOpen = actionsOpenKey === row.key;
|
||||
const badgeClass =
|
||||
row.kind === "direct"
|
||||
? "data-table-badge--direct"
|
||||
@@ -431,7 +467,15 @@ function renderRow(
|
||||
|
||||
return html`
|
||||
<tr>
|
||||
<td>
|
||||
<td class="data-table-checkbox-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${selected}
|
||||
@change=${() => onToggleSelect(row.key)}
|
||||
aria-label="Select session"
|
||||
/>
|
||||
</td>
|
||||
<td class="data-table-key-col">
|
||||
<div class="mono session-key-cell">
|
||||
${
|
||||
canLink
|
||||
@@ -551,68 +595,6 @@ function renderRow(
|
||||
)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div class="data-table-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="data-table-row-actions__trigger"
|
||||
aria-label="Open menu"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
onActionsOpenChange(isMenuOpen ? null : row.key);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
${
|
||||
isMenuOpen
|
||||
? html`
|
||||
<div class="data-table-row-actions__menu">
|
||||
${
|
||||
canLink
|
||||
? html`
|
||||
<a
|
||||
href=${chatUrl}
|
||||
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
|
||||
@click=${(e: MouseEvent) => {
|
||||
onActionsOpenChange(null);
|
||||
if (
|
||||
e.defaultPrevented ||
|
||||
e.button !== 0 ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.shiftKey ||
|
||||
e.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (onNavigateToChat) {
|
||||
e.preventDefault();
|
||||
onNavigateToChat(row.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open in Chat
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click=${() => {
|
||||
onActionsOpenChange(null);
|
||||
onDelete(row.key);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default defineConfig({
|
||||
"ui/src/ui/controllers/agents.test.ts",
|
||||
"ui/src/ui/controllers/chat.test.ts",
|
||||
"ui/src/ui/controllers/sessions.test.ts",
|
||||
"ui/src/ui/views/sessions.test.ts",
|
||||
"ui/src/ui/app-gateway.sessions.node.test.ts",
|
||||
],
|
||||
setupFiles: ["test/setup.ts"],
|
||||
|
||||
Reference in New Issue
Block a user