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:
Val Alexander
2026-03-21 18:33:05 -05:00
committed by GitHub
parent 91f404dc7e
commit 36c6d44eca
11 changed files with 442 additions and 253 deletions

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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>
`;
}

View File

@@ -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"],