mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
improve(ui): keep sidebar sessions inline and draggable (#102558)
* feat(ui): add drag-managed sidebar session groups * chore: keep release notes in PR body
This commit is contained in:
parent
51b4e78c33
commit
ecc2cffad8
12 changed files with 692 additions and 197 deletions
|
|
@ -34,10 +34,18 @@ import { resolveSessionDisplayName } from "../lib/session-display.ts";
|
|||
import {
|
||||
dissolveSessionGroup,
|
||||
loadStoredSessionCustomGroups,
|
||||
reorderSessionCustomGroups,
|
||||
renameSessionGroup,
|
||||
saveStoredSessionCustomGroups,
|
||||
} from "../lib/sessions/custom-groups.ts";
|
||||
import { writeSessionDragData } from "../lib/sessions/drag.ts";
|
||||
import {
|
||||
readSessionDragData,
|
||||
readSessionGroupDragData,
|
||||
sessionDragActive,
|
||||
sessionGroupDragActive,
|
||||
writeSessionDragData,
|
||||
writeSessionGroupDragData,
|
||||
} from "../lib/sessions/drag.ts";
|
||||
import {
|
||||
groupSidebarSessionRows,
|
||||
normalizeSidebarSessionsGrouping,
|
||||
|
|
@ -92,8 +100,14 @@ type SidebarSessionGroupMenuState = {
|
|||
};
|
||||
|
||||
type SidebarSessionSortMode = "created" | "updated";
|
||||
type SidebarSessionGroupDropTarget = {
|
||||
group: string;
|
||||
position: "before" | "after";
|
||||
};
|
||||
|
||||
const SIDEBAR_SESSION_GROUPING_STORAGE_KEY = "openclaw:sidebar:sessions:grouping";
|
||||
const SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY =
|
||||
"openclaw:sidebar:sessions:collapsed-sections";
|
||||
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
|
|
@ -105,6 +119,20 @@ function loadStoredSidebarSessionsGrouping(): SidebarSessionsGrouping {
|
|||
);
|
||||
}
|
||||
|
||||
function loadStoredCollapsedSessionSections(): ReadonlySet<string> {
|
||||
try {
|
||||
const raw = getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return new Set(
|
||||
Array.isArray(parsed)
|
||||
? parsed.flatMap((value) => (typeof value === "string" && value ? [value] : []))
|
||||
: [],
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const SIDEBAR_SESSION_SORT_OPTIONS = [
|
||||
{ mode: "created", labelKey: "chat.sidebar.sortCreated" },
|
||||
{ mode: "updated", labelKey: "chat.sidebar.sortUpdated" },
|
||||
|
|
@ -165,6 +193,10 @@ class AppSidebar extends LitElement {
|
|||
@state() private sessionGroupSubmenuOpen = false;
|
||||
@state() private sessionGroupMenu: SidebarSessionGroupMenuState | null = null;
|
||||
@state() private draggingSessionKey: string | null = null;
|
||||
@state() private draggingSessionGroup: string | null = null;
|
||||
@state() private sessionDropTarget: string | null = null;
|
||||
@state() private sessionGroupDropTarget: SidebarSessionGroupDropTarget | null = null;
|
||||
@state() private collapsedSessionSections = loadStoredCollapsedSessionSections();
|
||||
@state() private sessionSortMode: SidebarSessionSortMode = "created";
|
||||
@state() private sessionsGrouping: SidebarSessionsGrouping = loadStoredSidebarSessionsGrouping();
|
||||
@state() private sessionSortMenuPosition: { x: number; y: number } | null = null;
|
||||
|
|
@ -376,13 +408,13 @@ class AppSidebar extends LitElement {
|
|||
category: normalizeOptionalString(row.category),
|
||||
unread: row.unread === true,
|
||||
});
|
||||
const recentSessions = navigation.recentSessions.map(toSidebarSession);
|
||||
const visibleSessions = navigation.visibleSessions.map(toSidebarSession);
|
||||
const newSessionDisabled =
|
||||
!this.connected || this.sessionsLoading || Boolean(navigation.selectedSession?.hasActiveRun);
|
||||
return {
|
||||
routeSessionKey: navigation.currentSessionKey,
|
||||
selectedAgentId: navigation.selectedAgentId,
|
||||
recentSessions,
|
||||
visibleSessions,
|
||||
newSessionDisabled,
|
||||
newSessionTitle: !this.connected
|
||||
? "Connect to create a new session"
|
||||
|
|
@ -649,12 +681,13 @@ class AppSidebar extends LitElement {
|
|||
}
|
||||
|
||||
private knownSessionGroups(): string[] {
|
||||
const loaded = (this.sessionsResult?.sessions ?? [])
|
||||
const stored = loadStoredSessionCustomGroups();
|
||||
const storedSet = new Set(stored);
|
||||
const discovered = (this.sessionsResult?.sessions ?? [])
|
||||
.map((row) => normalizeOptionalString(row.category))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
return [...new Set([...loadStoredSessionCustomGroups(), ...loaded])].toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
.filter((name): name is string => typeof name === "string" && !storedSet.has(name))
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
return [...stored, ...new Set(discovered)];
|
||||
}
|
||||
|
||||
private rememberSessionGroup(name: string) {
|
||||
|
|
@ -695,7 +728,19 @@ class AppSidebar extends LitElement {
|
|||
if (!next || next === group) {
|
||||
return;
|
||||
}
|
||||
void renameSessionGroup(context.sessions, group, next).finally(() => this.requestUpdate());
|
||||
// Seed browser-local order with server-discovered groups so rename replaces
|
||||
// the existing slot instead of promoting the renamed group to the front.
|
||||
saveStoredSessionCustomGroups(this.knownSessionGroups());
|
||||
void renameSessionGroup(context.sessions, group, next).finally(() => {
|
||||
const from = `category:${group}`;
|
||||
if (this.collapsedSessionSections.has(from)) {
|
||||
const collapsed = new Set(this.collapsedSessionSections);
|
||||
collapsed.delete(from);
|
||||
collapsed.add(`category:${next}`);
|
||||
this.saveCollapsedSessionSections(collapsed);
|
||||
}
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private deleteSessionGroupFromMenu(group: string) {
|
||||
|
|
@ -706,7 +751,113 @@ class AppSidebar extends LitElement {
|
|||
if (!window.confirm(t("sessionsView.deleteGroupConfirm", { group }))) {
|
||||
return;
|
||||
}
|
||||
void dissolveSessionGroup(context.sessions, group).finally(() => this.requestUpdate());
|
||||
void dissolveSessionGroup(context.sessions, group).finally(() => {
|
||||
const collapsed = new Set(this.collapsedSessionSections);
|
||||
collapsed.delete(`category:${group}`);
|
||||
this.saveCollapsedSessionSections(collapsed);
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private saveCollapsedSessionSections(sections: ReadonlySet<string>) {
|
||||
this.collapsedSessionSections = new Set(sections);
|
||||
try {
|
||||
getSafeLocalStorage()?.setItem(
|
||||
SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY,
|
||||
JSON.stringify([...sections]),
|
||||
);
|
||||
} catch {
|
||||
// Group membership and ordering remain usable without local persistence.
|
||||
}
|
||||
}
|
||||
|
||||
private toggleSessionSection(sectionId: string) {
|
||||
const collapsed = new Set(this.collapsedSessionSections);
|
||||
if (collapsed.has(sectionId)) {
|
||||
collapsed.delete(sectionId);
|
||||
} else {
|
||||
collapsed.add(sectionId);
|
||||
}
|
||||
this.saveCollapsedSessionSections(collapsed);
|
||||
}
|
||||
|
||||
private reorderSessionGroup(source: string, target: string, position: "before" | "after") {
|
||||
const groups = reorderSessionCustomGroups(this.knownSessionGroups(), source, target, position);
|
||||
saveStoredSessionCustomGroups(groups);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleSessionSectionDragOver(event: DragEvent, sectionId: string, category?: string) {
|
||||
const dataTransfer = event.dataTransfer;
|
||||
if (
|
||||
category &&
|
||||
sessionGroupDragActive(dataTransfer) &&
|
||||
this.draggingSessionGroup !== category
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (dataTransfer) {
|
||||
dataTransfer.dropEffect = "move";
|
||||
}
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const bounds = target.getBoundingClientRect();
|
||||
const position = event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
this.sessionGroupDropTarget = { group: category, position };
|
||||
this.sessionDropTarget = null;
|
||||
return;
|
||||
}
|
||||
if (!sessionDragActive(dataTransfer) || sectionId === "pinned") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (dataTransfer) {
|
||||
dataTransfer.dropEffect = "move";
|
||||
}
|
||||
this.sessionDropTarget = sectionId;
|
||||
this.sessionGroupDropTarget = null;
|
||||
}
|
||||
|
||||
private handleSessionSectionDragLeave(event: DragEvent, sectionId: string, category?: string) {
|
||||
const current = event.currentTarget as HTMLElement;
|
||||
if (event.relatedTarget instanceof Node && current.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
if (this.sessionDropTarget === sectionId) {
|
||||
this.sessionDropTarget = null;
|
||||
}
|
||||
if (category && this.sessionGroupDropTarget?.group === category) {
|
||||
this.sessionGroupDropTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionSectionDrop(event: DragEvent, category?: string) {
|
||||
event.preventDefault();
|
||||
const sourceGroup = readSessionGroupDragData(event.dataTransfer);
|
||||
if (sourceGroup && category && sourceGroup !== category) {
|
||||
const position =
|
||||
this.sessionGroupDropTarget?.group === category
|
||||
? this.sessionGroupDropTarget.position
|
||||
: "before";
|
||||
this.reorderSessionGroup(sourceGroup, category, position);
|
||||
} else {
|
||||
const sessionKey = readSessionDragData(event.dataTransfer);
|
||||
const session = this.getSessionNavigationState().visibleSessions.find(
|
||||
(candidate) => candidate.key === sessionKey,
|
||||
);
|
||||
const nextCategory = category ?? null;
|
||||
if (session && (session.category !== nextCategory || session.pinned)) {
|
||||
if (category) {
|
||||
this.rememberSessionGroup(category);
|
||||
}
|
||||
void this.patchSession(session, {
|
||||
category: nextCategory,
|
||||
...(session.pinned ? { pinned: false } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
this.draggingSessionKey = null;
|
||||
this.draggingSessionGroup = null;
|
||||
this.sessionDropTarget = null;
|
||||
this.sessionGroupDropTarget = null;
|
||||
}
|
||||
|
||||
private setSessionsGrouping(grouping: SidebarSessionsGrouping) {
|
||||
|
|
@ -1282,6 +1433,7 @@ class AppSidebar extends LitElement {
|
|||
}}
|
||||
@dragend=${() => {
|
||||
this.draggingSessionKey = null;
|
||||
this.sessionDropTarget = null;
|
||||
}}
|
||||
@contextmenu=${(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -1364,12 +1516,129 @@ class AppSidebar extends LitElement {
|
|||
return keyed(session.key, row);
|
||||
}
|
||||
|
||||
private renderSessionSection(
|
||||
section: {
|
||||
id: string;
|
||||
category?: string;
|
||||
rows: SidebarRecentSession[];
|
||||
},
|
||||
showFallback = false,
|
||||
) {
|
||||
const group = section.category;
|
||||
const isPinned = section.id === "pinned";
|
||||
const showHeader = isPinned || this.sessionsGrouping === "category";
|
||||
const collapsed = showHeader && this.collapsedSessionSections.has(section.id);
|
||||
const label = isPinned ? t("sessionsView.pinned") : group ? group : t("sessionsView.ungrouped");
|
||||
const acceptsSessions = !isPinned && this.sessionsGrouping === "category";
|
||||
const sectionClass = [
|
||||
"sidebar-recent-sessions__group",
|
||||
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
|
||||
this.sessionDropTarget === section.id ? "sidebar-recent-sessions__group--session-drop" : "",
|
||||
group && this.sessionGroupDropTarget?.group === group
|
||||
? `sidebar-recent-sessions__group--group-drop-${this.sessionGroupDropTarget.position}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return html`
|
||||
<div
|
||||
class=${sectionClass}
|
||||
data-session-section=${section.id}
|
||||
@dragover=${acceptsSessions || group
|
||||
? (event: DragEvent) => this.handleSessionSectionDragOver(event, section.id, group)
|
||||
: nothing}
|
||||
@dragleave=${acceptsSessions || group
|
||||
? (event: DragEvent) => this.handleSessionSectionDragLeave(event, section.id, group)
|
||||
: nothing}
|
||||
@drop=${acceptsSessions || group
|
||||
? (event: DragEvent) => this.handleSessionSectionDrop(event, group)
|
||||
: nothing}
|
||||
>
|
||||
${showHeader
|
||||
? html`
|
||||
<div
|
||||
class="sidebar-recent-sessions__head"
|
||||
@contextmenu=${group
|
||||
? (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.openSessionGroupMenu(group, event.clientX, event.clientY, null);
|
||||
}
|
||||
: nothing}
|
||||
>
|
||||
${group
|
||||
? html`
|
||||
<span
|
||||
class="sidebar-session-group-drag-handle"
|
||||
draggable="true"
|
||||
aria-hidden="true"
|
||||
@dragstart=${(event: DragEvent) => {
|
||||
if (event.dataTransfer) {
|
||||
writeSessionGroupDragData(event.dataTransfer, group);
|
||||
this.draggingSessionGroup = group;
|
||||
}
|
||||
}}
|
||||
@dragend=${() => {
|
||||
this.draggingSessionGroup = null;
|
||||
this.sessionGroupDropTarget = null;
|
||||
}}
|
||||
></span>
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-group-toggle"
|
||||
aria-expanded=${String(!collapsed)}
|
||||
aria-label=${label}
|
||||
@click=${() => this.toggleSessionSection(section.id)}
|
||||
>
|
||||
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
|
||||
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
|
||||
>
|
||||
<span class="sidebar-recent-sessions__label-text">${label}</span>
|
||||
<span class="sidebar-session-group-count">${section.rows.length}</span>
|
||||
</button>
|
||||
${group
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-group-actions"
|
||||
title=${t("sessionsView.groupMenu", { group })}
|
||||
aria-label=${t("sessionsView.groupMenu", { group })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(this.sessionGroupMenu?.group === group)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
this.openSessionGroupMenu(group, rect.right, rect.bottom + 4, trigger);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${collapsed
|
||||
? nothing
|
||||
: html`
|
||||
<div class="sidebar-recent-sessions__list">
|
||||
${showFallback
|
||||
? this.renderChatFallback()
|
||||
: section.rows.map((session) => this.renderRecentSession(session))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSessions() {
|
||||
const context = this.context;
|
||||
const {
|
||||
routeSessionKey,
|
||||
selectedAgentId,
|
||||
recentSessions,
|
||||
visibleSessions,
|
||||
newSessionDisabled,
|
||||
newSessionTitle,
|
||||
} = this.getSessionNavigationState();
|
||||
|
|
@ -1412,13 +1681,12 @@ class AppSidebar extends LitElement {
|
|||
: newSessionButton;
|
||||
// Stable navigation ordering carries through each pinned/category bucket;
|
||||
// selecting a visible row only moves the active highlight.
|
||||
const sections = groupSidebarSessionRows(recentSessions, {
|
||||
const sections = groupSidebarSessionRows(visibleSessions, {
|
||||
grouping: this.sessionsGrouping,
|
||||
// Stored-but-empty groups stay visible as sections so a freshly created
|
||||
// group is usable as a move target before its first session arrives.
|
||||
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : undefined,
|
||||
});
|
||||
const hasCategorySections = sections.some((section) => section.category !== undefined);
|
||||
return html`
|
||||
<section class="sidebar-sessions ${this.collapsed ? "sidebar-sessions--collapsed" : ""}">
|
||||
${this.collapsed
|
||||
|
|
@ -1430,110 +1698,46 @@ class AppSidebar extends LitElement {
|
|||
? nothing
|
||||
: html`
|
||||
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
|
||||
${sections.map((section) => {
|
||||
if (section.id === "pinned" || section.category !== undefined) {
|
||||
const group = section.category;
|
||||
return html`
|
||||
<div class="sidebar-recent-sessions__group">
|
||||
<div
|
||||
class="sidebar-recent-sessions__head"
|
||||
@contextmenu=${group
|
||||
? (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.openSessionGroupMenu(
|
||||
group,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
null,
|
||||
);
|
||||
}
|
||||
: nothing}
|
||||
>
|
||||
<span class="sidebar-recent-sessions__label-text"
|
||||
>${section.id === "pinned"
|
||||
? t("sessionsView.pinned")
|
||||
: section.category}</span
|
||||
>
|
||||
${group
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-group-actions"
|
||||
title=${t("sessionsView.groupMenu", { group })}
|
||||
aria-label=${t("sessionsView.groupMenu", { group })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(this.sessionGroupMenu?.group === group)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
this.openSessionGroupMenu(
|
||||
group,
|
||||
rect.right,
|
||||
rect.bottom + 4,
|
||||
trigger,
|
||||
);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="sidebar-recent-sessions__list">
|
||||
${section.rows.map((session) => this.renderRecentSession(session))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="sidebar-recent-sessions__group">
|
||||
<div class="sidebar-recent-sessions__head">
|
||||
<span class="sidebar-recent-sessions__label-text"
|
||||
>${hasCategorySections && section.rows.length > 0
|
||||
? t("sessionsView.ungrouped")
|
||||
: t("sessionsView.title")}</span
|
||||
>
|
||||
${this.renderAgentScope(routeSessionKey, selectedAgentId)}
|
||||
<div class="sidebar-recent-sessions__head sidebar-recent-sessions__head--root">
|
||||
<span class="sidebar-recent-sessions__label-text"
|
||||
>${t("sessionsView.title")}</span
|
||||
>
|
||||
${this.renderAgentScope(routeSessionKey, selectedAgentId)}
|
||||
${this.sessionsGrouping === "category"
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-sort"
|
||||
title=${t("chat.sidebar.sortSessions")}
|
||||
aria-label=${t("chat.sidebar.sortSessions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(this.sessionSortMenuPosition !== null)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
this.toggleSessionSortMenu(trigger);
|
||||
}}
|
||||
title=${t("sessionsView.newGroup")}
|
||||
aria-label=${t("sessionsView.newGroup")}
|
||||
?disabled=${!this.connected}
|
||||
@click=${() => this.createSessionGroup()}
|
||||
>
|
||||
${icons.listFilter}
|
||||
${icons.plus}
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-recent-sessions__list">
|
||||
${recentSessions.length === 0
|
||||
? this.renderChatFallback()
|
||||
: section.rows.map((session) => this.renderRecentSession(session))}
|
||||
</div>
|
||||
<a
|
||||
href=${pathForRoute("sessions", this.basePath)}
|
||||
class="sidebar-recent-sessions__all"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (!shouldHandleNavigationClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.onNavigate?.("sessions");
|
||||
}}
|
||||
>
|
||||
<span>${t("chat.sidebar.allSessions")}</span>
|
||||
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
|
||||
>${icons.chevronRight}</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
: nothing}
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-sort"
|
||||
title=${t("chat.sidebar.sortSessions")}
|
||||
aria-label=${t("chat.sidebar.sortSessions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(this.sessionSortMenuPosition !== null)}
|
||||
@click=${(event: MouseEvent) => {
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
this.toggleSessionSortMenu(trigger);
|
||||
}}
|
||||
>
|
||||
${icons.listFilter}
|
||||
</button>
|
||||
</div>
|
||||
${sections.map((section) =>
|
||||
this.renderSessionSection(
|
||||
section,
|
||||
visibleSessions.length === 0 && section.id === "ungrouped",
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
it("manages sessions through the sidebar groups and command palette", async () => {
|
||||
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
|
|
@ -241,7 +242,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
|
||||
// Chats keep recency order with the open session highlighted in place —
|
||||
// selecting a row must not reshuffle the list.
|
||||
const chatRows = groups.nth(1).locator(".sidebar-recent-session");
|
||||
const chatRows = page.locator('[data-session-section="ungrouped"] .sidebar-recent-session');
|
||||
const rowNames = () =>
|
||||
chatRows.evaluateAll((rows) =>
|
||||
rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""),
|
||||
|
|
@ -302,7 +303,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
const researchLink = sidebarResearch.locator("a").first();
|
||||
await researchLink.click();
|
||||
await expect.poll(() => page.url()).toContain("session=agent%3Amain%3Aresearch");
|
||||
await expect.poll(rowNames).toEqual(["Main", "Data migration", "Research notes"]);
|
||||
await expect
|
||||
.poll(rowNames)
|
||||
.toEqual(["Main", "Release planning", "Data migration", "Research notes"]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
chatRows
|
||||
|
|
@ -386,6 +389,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
match: {},
|
||||
response: sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", baseTime),
|
||||
sessionRow("agent:main:apps", "Apps", baseTime - 30_000, {
|
||||
category: "Apps",
|
||||
}),
|
||||
sessionRow("agent:main:paper-a", "Paper A", baseTime - 60_000, {
|
||||
category: "Research",
|
||||
}),
|
||||
|
|
@ -429,11 +435,25 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
);
|
||||
expect(requireRecord(patch.params)).toMatchObject({ category: "Projects", key });
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-session-section^="category:"]')
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-session-section")),
|
||||
),
|
||||
)
|
||||
.toEqual(["category:Apps", "category:Projects"]);
|
||||
|
||||
// Delete group: sessions survive and move back to Ungrouped.
|
||||
await researchGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
const projectsGroup = groups.filter({ hasText: "Projects" });
|
||||
await projectsGroup.waitFor({ state: "visible" });
|
||||
const projectsMenuButton = projectsGroup.getByRole("button", {
|
||||
name: "Group options for Projects",
|
||||
});
|
||||
await projectsGroup.locator(".sidebar-recent-sessions__head").hover();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await groupMenuButton.click();
|
||||
await projectsMenuButton.click();
|
||||
await page.getByRole("menuitem", { name: "Delete group…" }).click();
|
||||
const dissolvePatch = await waitForPatch(
|
||||
gateway,
|
||||
|
|
@ -457,7 +477,152 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
await sortSessionsButton.click();
|
||||
await page.getByRole("menuitemradio", { name: "None" }).click();
|
||||
await expect.poll(() => groups.count()).toBe(1);
|
||||
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
|
||||
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(4);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows every sidebar session and supports complete drag-managed groups", async () => {
|
||||
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const sessions = Array.from({ length: 12 }, (_, index) =>
|
||||
sessionRow(`agent:main:session-${index}`, `Session ${index}`, baseTime - index * 60_000, {
|
||||
...(index === 0 ? { category: "Alpha" } : {}),
|
||||
...(index === 1 ? { category: "Beta" } : {}),
|
||||
}),
|
||||
);
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse(sessions),
|
||||
"sessions.patch": {},
|
||||
},
|
||||
sessionKey: "agent:main:session-0",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const sidebarRows = page.locator(".sidebar-recent-session");
|
||||
await expect.poll(() => sidebarRows.count()).toBe(12);
|
||||
await expect.poll(() => page.getByText("All sessions", { exact: true }).count()).toBe(0);
|
||||
await captureUiProof(page, "sidebar-all-sessions.png");
|
||||
|
||||
page.once("dialog", (dialog) => void dialog.accept("Gamma"));
|
||||
await page.getByRole("button", { name: "New group…" }).click();
|
||||
const gamma = page.locator('[data-session-section="category:Gamma"]');
|
||||
await gamma.waitFor({ state: "visible" });
|
||||
|
||||
const sessionEleven = page.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-11"]',
|
||||
);
|
||||
await sessionEleven.dragTo(gamma);
|
||||
const groupedPatch = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === "agent:main:session-11" && params.category === "Gamma",
|
||||
);
|
||||
expect(requireRecord(groupedPatch.params)).toMatchObject({
|
||||
category: "Gamma",
|
||||
key: "agent:main:session-11",
|
||||
});
|
||||
await expect
|
||||
.poll(() => gamma.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
|
||||
.toBe(1);
|
||||
await captureUiProof(page, "sidebar-session-dropped-into-group.png");
|
||||
|
||||
const ungrouped = page.locator('[data-session-section="ungrouped"]');
|
||||
await gamma.locator(".sidebar-recent-session").dragTo(ungrouped);
|
||||
const ungroupedPatch = await waitForPatch(
|
||||
gateway,
|
||||
(params) => params.key === "agent:main:session-11" && params.category === null,
|
||||
);
|
||||
expect(requireRecord(ungroupedPatch.params)).toMatchObject({
|
||||
category: null,
|
||||
key: "agent:main:session-11",
|
||||
});
|
||||
await expect
|
||||
.poll(() => ungrouped.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
|
||||
.toBe(10);
|
||||
|
||||
const alpha = page.locator('[data-session-section="category:Alpha"]');
|
||||
const alphaToggle = alpha.getByRole("button", { name: "Alpha", exact: true });
|
||||
await alphaToggle.click();
|
||||
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
await captureUiProof(page, "sidebar-session-group-collapsed.png");
|
||||
await alphaToggle.click();
|
||||
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
await alphaToggle.click();
|
||||
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
|
||||
await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, {
|
||||
targetPosition: { x: 4, y: 2 },
|
||||
});
|
||||
const customGroupOrder = () =>
|
||||
page
|
||||
.locator('[data-session-section^="category:"]')
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-session-section")),
|
||||
);
|
||||
await expect
|
||||
.poll(customGroupOrder)
|
||||
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
|
||||
await captureUiProof(page, "sidebar-session-groups-reordered.png");
|
||||
|
||||
await page.reload();
|
||||
await expect
|
||||
.poll(customGroupOrder)
|
||||
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-session-section="category:Alpha"] .sidebar-session-group-toggle')
|
||||
.getAttribute("aria-expanded"),
|
||||
)
|
||||
.toBe("false");
|
||||
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11);
|
||||
|
||||
const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length;
|
||||
const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" });
|
||||
await sortSessionsButton.click();
|
||||
await page.getByRole("menuitemradio", { name: "None" }).click();
|
||||
const flatSection = page.locator('[data-session-section="ungrouped"]');
|
||||
await flatSection
|
||||
.locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]')
|
||||
.dragTo(flatSection);
|
||||
expect((await gateway.getRequests("sessions.patch")).length).toBe(patchCountBeforeFlatDrag);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a new empty group visible before the first saved session", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([]),
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
page.once("dialog", (dialog) => void dialog.accept("First group"));
|
||||
await page.getByRole("button", { name: "New group…" }).click();
|
||||
await page.locator('[data-session-section="category:First group"]').waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
|
@ -470,13 +635,14 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), {
|
||||
pinned: true,
|
||||
}),
|
||||
]),
|
||||
"sessions.patch": {},
|
||||
},
|
||||
sessionKey: "agent:main:pinned",
|
||||
});
|
||||
|
|
@ -484,14 +650,26 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
|||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
|
||||
const sessionGroups = page.locator(".sidebar-recent-sessions__group");
|
||||
const pinnedGroup = sessionGroups.filter({ hasText: "Pinned" });
|
||||
const chatsGroup = sessionGroups.filter({ hasText: "Sessions" });
|
||||
const pinnedGroup = page.locator('[data-session-section="pinned"]');
|
||||
const chatsGroup = page.locator('[data-session-section="ungrouped"]');
|
||||
await expect
|
||||
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
|
||||
.toEqual(["Pinned only"]);
|
||||
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1);
|
||||
await pinnedGroup.locator(".sidebar-recent-session").dragTo(chatsGroup);
|
||||
const unpinPatch = await waitForPatch(
|
||||
gateway,
|
||||
(params) =>
|
||||
params.key === "agent:main:pinned" && params.category === null && params.pinned === false,
|
||||
);
|
||||
expect(requireRecord(unpinPatch.params)).toMatchObject({
|
||||
category: null,
|
||||
key: "agent:main:pinned",
|
||||
pinned: false,
|
||||
});
|
||||
await expect.poll(() => pinnedGroup.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createStorageMock } from "../../test-helpers/storage.ts";
|
|||
import {
|
||||
dissolveSessionGroup,
|
||||
loadStoredSessionCustomGroups,
|
||||
reorderSessionCustomGroups,
|
||||
renameSessionGroup,
|
||||
saveStoredSessionCustomGroups,
|
||||
} from "./custom-groups.ts";
|
||||
|
|
@ -132,6 +133,24 @@ describe("renameSessionGroup", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("reorderSessionCustomGroups", () => {
|
||||
it("moves a group before the drop target and keeps the rest stable", () => {
|
||||
expect(reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Gamma", "Alpha")).toEqual([
|
||||
"Gamma",
|
||||
"Alpha",
|
||||
"Beta",
|
||||
]);
|
||||
expect(reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Alpha", "Gamma")).toEqual([
|
||||
"Beta",
|
||||
"Alpha",
|
||||
"Gamma",
|
||||
]);
|
||||
expect(
|
||||
reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Alpha", "Gamma", "after"),
|
||||
).toEqual(["Beta", "Gamma", "Alpha"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dissolveSessionGroup", () => {
|
||||
it("drops the stored group and moves member sessions back to ungrouped", async () => {
|
||||
saveStoredSessionCustomGroups(["Research", "Apps"]);
|
||||
|
|
|
|||
|
|
@ -27,12 +27,32 @@ export function loadStoredSessionCustomGroups(): string[] {
|
|||
|
||||
export function saveStoredSessionCustomGroups(groups: readonly string[]) {
|
||||
try {
|
||||
getSafeLocalStorage()?.setItem(SESSION_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(groups));
|
||||
const normalized = [...new Set(groups.map((name) => name.trim()).filter(Boolean))];
|
||||
getSafeLocalStorage()?.setItem(SESSION_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
} catch {
|
||||
// Assigned groups still persist server-side via the session category field.
|
||||
}
|
||||
}
|
||||
|
||||
/** Move one custom group before another while preserving every other group. */
|
||||
export function reorderSessionCustomGroups(
|
||||
groups: readonly string[],
|
||||
source: string,
|
||||
target: string,
|
||||
position: "before" | "after" = "before",
|
||||
): string[] {
|
||||
const ordered = [...new Set(groups.map((name) => name.trim()).filter(Boolean))];
|
||||
const sourceIndex = ordered.indexOf(source);
|
||||
const targetIndex = ordered.indexOf(target);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
return ordered;
|
||||
}
|
||||
const [moved] = ordered.splice(sourceIndex, 1);
|
||||
const targetInsertionIndex = ordered.indexOf(target) + (position === "after" ? 1 : 0);
|
||||
ordered.splice(targetInsertionIndex, 0, moved);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
type SessionGroupClient = Pick<SessionCapability, "list" | "patch">;
|
||||
|
||||
// The gateway caps sessions.list at a 100-row default when no limit is sent
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Private MIME keeps stray text and file drags from becoming session actions.
|
||||
export const SESSION_DRAG_MIME = "application/x-openclaw-session-key";
|
||||
export const SESSION_GROUP_DRAG_MIME = "application/x-openclaw-session-group";
|
||||
|
||||
export function writeSessionDragData(dataTransfer: DataTransfer, sessionKey: string): void {
|
||||
dataTransfer.setData(SESSION_DRAG_MIME, sessionKey);
|
||||
dataTransfer.setData("text/plain", sessionKey);
|
||||
dataTransfer.effectAllowed = "copy";
|
||||
// Sidebar sessions can move between groups or copy into a chat split pane.
|
||||
dataTransfer.effectAllowed = "copyMove";
|
||||
}
|
||||
|
||||
export function readSessionDragData(dataTransfer: DataTransfer | null): string | null {
|
||||
|
|
@ -15,3 +17,17 @@ export function readSessionDragData(dataTransfer: DataTransfer | null): string |
|
|||
export function sessionDragActive(dataTransfer: DataTransfer | null): boolean {
|
||||
return Array.from(dataTransfer?.types ?? []).includes(SESSION_DRAG_MIME);
|
||||
}
|
||||
|
||||
export function writeSessionGroupDragData(dataTransfer: DataTransfer, group: string): void {
|
||||
dataTransfer.setData(SESSION_GROUP_DRAG_MIME, group);
|
||||
dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
|
||||
export function readSessionGroupDragData(dataTransfer: DataTransfer | null): string | null {
|
||||
const group = dataTransfer?.getData(SESSION_GROUP_DRAG_MIME).trim();
|
||||
return group || null;
|
||||
}
|
||||
|
||||
export function sessionGroupDragActive(dataTransfer: DataTransfer | null): boolean {
|
||||
return Array.from(dataTransfer?.types ?? []).includes(SESSION_GROUP_DRAG_MIME);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ describe("groupSidebarSessionRows", () => {
|
|||
expect(sections[1]?.rows.map((item) => item.key)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps custom groups in their persisted order", () => {
|
||||
const sections = groupSidebarSessionRows(
|
||||
[row({ key: "a", category: "Alpha" }), row({ key: "z", category: "Zulu" })],
|
||||
{ knownGroups: ["Zulu", "Alpha"] },
|
||||
);
|
||||
expect(sections.map((section) => section.id)).toEqual([
|
||||
"category:Zulu",
|
||||
"category:Alpha",
|
||||
"ungrouped",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses categories into the ungrouped list when grouping is none", () => {
|
||||
const sections = groupSidebarSessionRows(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ export function normalizeSidebarSessionsGrouping(raw: unknown): SidebarSessionsG
|
|||
}
|
||||
|
||||
/**
|
||||
* Pinned first, named categories alphabetically, then uncategorized rows.
|
||||
* Pinned first, named categories in the persisted `knownGroups` order, then
|
||||
* newly observed categories alphabetically, then uncategorized rows.
|
||||
* `knownGroups` keeps stored-but-empty groups visible as move targets;
|
||||
* `grouping: "none"` collapses categories into the ungrouped list (pinned stays).
|
||||
*/
|
||||
|
|
@ -156,7 +157,16 @@ export function groupSidebarSessionRows<Row extends { pinned?: boolean; category
|
|||
if (pinned.length > 0) {
|
||||
sections.push({ id: "pinned", rows: pinned });
|
||||
}
|
||||
for (const category of [...categories.keys()].toSorted((a, b) => a.localeCompare(b))) {
|
||||
const knownGroups = [
|
||||
...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)),
|
||||
];
|
||||
const orderedCategories = [
|
||||
...knownGroups.filter((name) => categories.has(name)),
|
||||
...[...categories.keys()]
|
||||
.filter((name) => !knownGroups.includes(name))
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
for (const category of orderedCategories) {
|
||||
sections.push({ id: `category:${category}`, category, rows: categories.get(category) ?? [] });
|
||||
}
|
||||
sections.push({ id: "ungrouped", rows: ungrouped });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("resolveSessionNavigation", () => {
|
|||
sessionKey: "agent:main:recent-3",
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions.map((row) => row.key)).toEqual(rows.map((row) => row.key));
|
||||
expect(navigation.visibleSessions.map((row) => row.key)).toEqual(rows.map((row) => row.key));
|
||||
expect(navigation.activeRowKey).toBe("agent:main:recent-3");
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ describe("resolveSessionNavigation", () => {
|
|||
compareSessions: (a, b) => a.key.localeCompare(b.key),
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
|
||||
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
|
||||
"agent:main:session-a",
|
||||
"agent:main:session-b",
|
||||
"agent:main:session-c",
|
||||
|
|
@ -49,7 +49,7 @@ describe("resolveSessionNavigation", () => {
|
|||
expect(navigation.activeRowKey).toBe("agent:main:session-b");
|
||||
});
|
||||
|
||||
it("pins the selected session ahead of the nine most recent rows when the list omits it", () => {
|
||||
it("keeps a deep-linked session ahead of every returned active row", () => {
|
||||
const navigation = resolveSessionNavigation({
|
||||
result: sessionsResult(
|
||||
Array.from({ length: 11 }, (_, index) => ({
|
||||
|
|
@ -62,19 +62,19 @@ describe("resolveSessionNavigation", () => {
|
|||
sessionKey: "agent:main:oldest",
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions).toHaveLength(10);
|
||||
expect(navigation.recentSessions[0]).toMatchObject({
|
||||
expect(navigation.visibleSessions).toHaveLength(12);
|
||||
expect(navigation.visibleSessions[0]).toMatchObject({
|
||||
key: "agent:main:oldest",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
});
|
||||
expect(navigation.activeRowKey).toBe("agent:main:oldest");
|
||||
expect(navigation.recentSessions.slice(1).map((row) => row.key)).toEqual(
|
||||
Array.from({ length: 9 }, (_, index) => `agent:main:recent-${index}`),
|
||||
expect(navigation.visibleSessions.slice(1).map((row) => row.key)).toEqual(
|
||||
Array.from({ length: 11 }, (_, index) => `agent:main:recent-${index}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the real row when the selected session sits beyond the recency cap", () => {
|
||||
it("keeps the selected session in place in a long list", () => {
|
||||
const rows = Array.from({ length: 12 }, (_, index) => ({
|
||||
key: `agent:main:recent-${index}`,
|
||||
kind: "direct" as const,
|
||||
|
|
@ -86,8 +86,8 @@ describe("resolveSessionNavigation", () => {
|
|||
sessionKey: "agent:main:recent-11",
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions[0]).toBe(rows[11]);
|
||||
expect(navigation.recentSessions).toHaveLength(10);
|
||||
expect(navigation.visibleSessions[11]).toBe(rows[11]);
|
||||
expect(navigation.visibleSessions).toHaveLength(12);
|
||||
expect(navigation.activeRowKey).toBe("agent:main:recent-11");
|
||||
});
|
||||
|
||||
|
|
@ -107,13 +107,13 @@ describe("resolveSessionNavigation", () => {
|
|||
sessionKey: "unknown",
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
|
||||
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
|
||||
...pinnedSessions.map((row) => row.key),
|
||||
"agent:main:recent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps nine recent chats in addition to pinned sessions", () => {
|
||||
it("keeps every active chat in addition to pinned sessions", () => {
|
||||
const pinnedSessions = Array.from({ length: 3 }, (_, index) => ({
|
||||
key: `agent:main:pinned-${index}`,
|
||||
kind: "direct" as const,
|
||||
|
|
@ -131,9 +131,9 @@ describe("resolveSessionNavigation", () => {
|
|||
sessionKey: "unknown",
|
||||
});
|
||||
|
||||
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
|
||||
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
|
||||
...pinnedSessions.map((row) => row.key),
|
||||
...recentSessions.slice(0, 9).map((row) => row.key),
|
||||
...recentSessions.map((row) => row.key),
|
||||
]);
|
||||
expect(navigation.activeRowKey).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export type SessionNavigation = {
|
|||
selectedAgentId: string;
|
||||
defaultAgentId: string;
|
||||
selectedSession?: GatewaySessionRow;
|
||||
recentSessions: GatewaySessionRow[];
|
||||
visibleSessions: GatewaySessionRow[];
|
||||
activeRowKey: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -249,26 +249,22 @@ export function resolveSessionNavigation(input: SessionNavigationInput): Session
|
|||
defaultAgentId,
|
||||
filterByAgent: shouldFilterByAgent,
|
||||
}).toSorted(input.compareSessions ?? compareSessionRowsByUpdatedAt);
|
||||
// Keep visible selections in their sorted slot. Hoisting every active row
|
||||
// makes the list move after each click. Pinned chats remain outside the
|
||||
// recent-chat cap so explicit pins never disappear.
|
||||
const pinnedSessions = sortedSessions.filter((row) => row.pinned === true);
|
||||
let recentSessions = [
|
||||
...pinnedSessions,
|
||||
...sortedSessions.filter((row) => row.pinned !== true).slice(0, 9),
|
||||
];
|
||||
let activeRow = recentSessions.find(matchesCurrentSession);
|
||||
// The sidebar is the session list, not a recent-session preview. Keep every
|
||||
// active row in its sorted slot so selecting a session never reshuffles or
|
||||
// hides another one behind a separate route.
|
||||
let visibleSessions = sortedSessions;
|
||||
let activeRow = visibleSessions.find(matchesCurrentSession);
|
||||
if (!activeRow && activeSession) {
|
||||
// Deep-linked, archived, and capped sessions still need a visible row.
|
||||
// Deep-linked and archived sessions still need a visible selected row.
|
||||
activeRow = sortedSessions.find(matchesCurrentSession) ?? activeSession;
|
||||
recentSessions = [activeRow, ...recentSessions.filter((row) => row !== activeRow)];
|
||||
visibleSessions = [activeRow, ...visibleSessions.filter((row) => row !== activeRow)];
|
||||
}
|
||||
return {
|
||||
currentSessionKey,
|
||||
selectedAgentId,
|
||||
defaultAgentId,
|
||||
selectedSession: activeSession,
|
||||
recentSessions,
|
||||
visibleSessions,
|
||||
activeRowKey: activeRow?.key ?? null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -599,13 +599,32 @@
|
|||
gap: 10px;
|
||||
margin: 0 -8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
min-height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__group--session-drop {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__group--group-drop-before {
|
||||
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__group--group-drop-after {
|
||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent) 75%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__head {
|
||||
|
|
@ -617,10 +636,70 @@
|
|||
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__head--root {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__head .sidebar-recent-sessions__label-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sidebar-session-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-session-group-toggle__icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.sidebar-session-group-toggle__icon svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.8px;
|
||||
}
|
||||
|
||||
.sidebar-session-group-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sidebar-session-group-drag-handle {
|
||||
width: 8px;
|
||||
height: 18px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 3px;
|
||||
background-image: radial-gradient(circle, currentColor 1px, transparent 1.2px);
|
||||
background-position: 0 0;
|
||||
background-size: 4px 4px;
|
||||
color: var(--muted);
|
||||
opacity: 0.55;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebar-session-group-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sidebar-session-sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -776,46 +855,7 @@
|
|||
.sidebar-recent-sessions__list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__all:hover {
|
||||
background: color-mix(in srgb, var(--bg-hover) 78%, transparent);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__all-icon {
|
||||
display: inline-flex;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sidebar-recent-sessions__all-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
min-height: 12px;
|
||||
}
|
||||
|
||||
.sidebar-recent-session--dragging {
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ function installControlUiMockGateway(input: {
|
|||
return;
|
||||
}
|
||||
const patch = { ...sessionPatches.get(params.key) };
|
||||
for (const key of ["model", "thinkingLevel", "fastMode"] as const) {
|
||||
for (const key of ["model", "thinkingLevel", "fastMode", "category", "pinned"] as const) {
|
||||
if (hasOwn(params, key)) {
|
||||
patch[key] = params[key];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue