mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
9b43f1c82e
Pinned sessions inherited the nav zone's muted colour on the session *title*, so a pinned row's preview line outshone its own name and the same session read dimmer pinned than unpinned. A title is content in every zone; only glyphs, meta and the hover fill follow the zone. The subtitle drops to plain --muted, already proven AA against every surface by theme-contrast.test.ts. Rows with no preview text reserved a fixed 18px second line anyway, leaving a dead band or a lone spinner hanging below-right of the title. They now collapse to one line via a --single-line class plus a CSS variant, so the endcap rides beside the title. Rows also gained a little vertical air and reclaimed the right-hand gutter inside the row, leaving the deliberate scrollbar clearance from #124879 intact. Adds a localStorage-backed "Show message preview" toggle to the session sort menu (default on). Operator-actionable state is exempt: attention, the queued concurrency-slot explanation, and critical observer headlines (stuck / waiting-on-user) always show, because hiding them behind a display preference is the silent-failure class. Also registers extensions/codex/src/app-server/run-attempt-tools.test.ts, which arrived in #126189 with no shard claim and left main red.
778 lines
27 KiB
TypeScript
778 lines
27 KiB
TypeScript
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import {
|
|
parseSidebarEntry,
|
|
serializeSidebarEntry,
|
|
type PersistedSidebarRoute,
|
|
} from "../app-navigation.ts";
|
|
import { t } from "../i18n/index.ts";
|
|
import {
|
|
readSessionDragData,
|
|
readSidebarSectionDragData,
|
|
readSidebarRouteDragData,
|
|
sessionDragActive,
|
|
sidebarSectionDragActive,
|
|
sidebarRouteDragActive,
|
|
writeSidebarRouteDragData,
|
|
} from "../lib/sessions/drag.ts";
|
|
import {
|
|
categoryClearReturnsToGroups,
|
|
type SidebarSessionsGrouping,
|
|
} from "../lib/sessions/grouping.ts";
|
|
import {
|
|
loadStoredCollapsedSessionSections,
|
|
storeSidebarSessionStatusFilter,
|
|
storeCollapsedSessionSections,
|
|
storeSidebarSessionsGrouping,
|
|
storeSidebarSessionsShowCron,
|
|
storeSidebarSessionsShowPreview,
|
|
storeSidebarSessionsShowSystem,
|
|
type SidebarRecentSession,
|
|
type SidebarSectionDropTarget,
|
|
type SidebarSessionMutationResult,
|
|
type SidebarSessionMutationScope,
|
|
type SidebarSessionPatch,
|
|
type SidebarSessionStatusFilter,
|
|
} from "./app-sidebar-session-types.ts";
|
|
import type { SessionMenuAction } from "./session-menu.ts";
|
|
import type { SessionOrganizerControllerHost } from "./session-organizer-controller-types.ts";
|
|
import type { SessionOwnerOption } from "./session-owner-chip.ts";
|
|
|
|
export type { SessionOrganizerControllerHost } from "./session-organizer-controller-types.ts";
|
|
|
|
type SessionOrganizerOperations = typeof import("./session-organizer-operations.runtime.ts");
|
|
type InputDialogOpener = (typeof import("./input-dialog.ts"))["showInputDialog"];
|
|
type SessionGroupDefaultsDialogOpener =
|
|
(typeof import("./session-group-defaults-dialog.ts"))["showSessionGroupDefaultsDialog"];
|
|
/** Custom session groups, collapse state, and drag-and-drop assignment. */
|
|
export class SessionOrganizerController {
|
|
collapsedSessionSections = loadStoredCollapsedSessionSections();
|
|
draggingSessionKey: string | null = null;
|
|
draggingSidebarSection: string | null = null;
|
|
sessionDropTarget: string | null = null;
|
|
sidebarSectionDropTarget: SidebarSectionDropTarget | null = null;
|
|
draggingSidebarEntry: string | null = null;
|
|
sidebarZoneDropTarget: {
|
|
entry: string;
|
|
position: "before" | "after";
|
|
} | null = null;
|
|
sessionListRemovalDrop = false;
|
|
private operationsLoad: Promise<SessionOrganizerOperations> | null = null;
|
|
|
|
constructor(private readonly host: SessionOrganizerControllerHost) {}
|
|
|
|
private async loadOperations(
|
|
scope: SidebarSessionMutationScope,
|
|
): Promise<SessionOrganizerOperations | null> {
|
|
const load = (this.operationsLoad ??= import("./session-organizer-operations.runtime.ts"));
|
|
try {
|
|
return await load;
|
|
} catch (error) {
|
|
if (this.operationsLoad === load) {
|
|
this.operationsLoad = null;
|
|
}
|
|
if (this.host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
|
this.host.sessionData.publishSessionMutationError(scope, error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
readonly patchSession = async (
|
|
session: SidebarRecentSession,
|
|
patch: SidebarSessionPatch,
|
|
scope: SidebarSessionMutationScope | null = this.host.sessionData.beginSessionMutation(),
|
|
): Promise<SidebarSessionMutationResult> => {
|
|
if (!scope) {
|
|
return "stale";
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
if (!operations) {
|
|
return this.host.sessionData.isSessionMutationScopeCurrent(scope) ? "failed" : "stale";
|
|
}
|
|
return operations.patchSession(this.host, session, patch, scope);
|
|
};
|
|
|
|
async patchSessions(
|
|
rows: readonly SidebarRecentSession[],
|
|
patch: SidebarSessionPatch,
|
|
scope: SidebarSessionMutationScope | null = this.host.sessionData.beginSessionMutation(),
|
|
): Promise<SidebarSessionMutationResult> {
|
|
if (!scope) {
|
|
return "stale";
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
if (!operations) {
|
|
return this.host.sessionData.isSessionMutationScopeCurrent(scope) ? "failed" : "stale";
|
|
}
|
|
return operations.patchSessions(this.host, rows, patch, scope);
|
|
}
|
|
|
|
async archiveSessionWithUndo(session: SidebarRecentSession): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.archiveSessionWithUndo(this.host, session, scope);
|
|
}
|
|
|
|
async deleteSessionsBatch(rows: readonly SidebarRecentSession[]): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.deleteSessionsBatch(this.host, rows, scope);
|
|
}
|
|
|
|
async runBatchSessionAction(
|
|
action: SessionMenuAction,
|
|
rows: SidebarRecentSession[],
|
|
allUnread: boolean,
|
|
): Promise<void> {
|
|
if (action.kind === "new-group") {
|
|
await this.createSessionGroup(rows);
|
|
return;
|
|
}
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.runBatchSessionAction(this.host, action, rows, allUnread, scope);
|
|
}
|
|
|
|
async forkSession(session: SidebarRecentSession): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.forkSession(this.host, session, scope);
|
|
}
|
|
|
|
async stopCloudWorker(session: SidebarRecentSession): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.stopCloudWorker(this.host, session, scope);
|
|
}
|
|
|
|
async assignSessionOwner(
|
|
session: SidebarRecentSession,
|
|
owner: Pick<SessionOwnerOption, "type" | "id">,
|
|
): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.assignSessionOwner(this.host, session, owner, scope);
|
|
}
|
|
|
|
async deleteSession(session: SidebarRecentSession): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
// Sidebar is the surface the delete-confirm setting names, so it is the one
|
|
// caller allowed to offer the opt-out.
|
|
await operations?.deleteSession(this.host, session, scope, { offerSkip: true });
|
|
}
|
|
|
|
startSidebarRouteDrag(event: DragEvent, route: PersistedSidebarRoute) {
|
|
if (!event.dataTransfer) {
|
|
return;
|
|
}
|
|
writeSidebarRouteDragData(event.dataTransfer, route);
|
|
this.draggingSidebarEntry = serializeSidebarEntry({ type: "route", route });
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
startSidebarWorkboardDrag(event: DragEvent, boardId: string) {
|
|
if (!event.dataTransfer) {
|
|
return;
|
|
}
|
|
const entry = serializeSidebarEntry({ type: "workboard", boardId });
|
|
writeSidebarRouteDragData(event.dataTransfer, entry);
|
|
this.draggingSidebarEntry = entry;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
finishSidebarEntryDrag() {
|
|
this.draggingSidebarEntry = null;
|
|
this.host.requestUpdate();
|
|
this.draggingSessionKey = null;
|
|
this.host.requestUpdate();
|
|
this.sidebarZoneDropTarget = null;
|
|
this.host.requestUpdate();
|
|
this.sessionListRemovalDrop = false;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
startSessionDrag(session: SidebarRecentSession): void {
|
|
this.draggingSessionKey = session.key;
|
|
this.host.requestUpdate();
|
|
this.draggingSidebarEntry = session.pinned ? `session:${session.key}` : null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
finishSessionDrag(): void {
|
|
this.finishSidebarEntryDrag();
|
|
this.sessionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
startSidebarSectionDrag(sectionId: string): void {
|
|
this.draggingSidebarSection = sectionId;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
finishSidebarSectionDrag(): void {
|
|
this.draggingSidebarSection = null;
|
|
this.host.requestUpdate();
|
|
this.sidebarSectionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
private draggedSidebarEntry(dataTransfer: DataTransfer | null): string | null {
|
|
const route = readSidebarRouteDragData(dataTransfer);
|
|
const routeEntry = parseSidebarEntry(route ? `route:${route}` : null);
|
|
if (routeEntry?.type === "route") {
|
|
return serializeSidebarEntry(routeEntry);
|
|
}
|
|
const dynamicEntry = parseSidebarEntry(route);
|
|
if (dynamicEntry?.type === "workboard") {
|
|
return serializeSidebarEntry(dynamicEntry);
|
|
}
|
|
const sessionKey = readSessionDragData(dataTransfer);
|
|
return sessionKey ? serializeSidebarEntry({ type: "session", key: sessionKey }) : null;
|
|
}
|
|
|
|
handleSidebarZoneDragOver(event: DragEvent, targetEntry?: string) {
|
|
if (!sidebarRouteDragActive(event.dataTransfer) && !sessionDragActive(event.dataTransfer)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = "move";
|
|
}
|
|
if (!targetEntry) {
|
|
this.sidebarZoneDropTarget = null;
|
|
this.host.requestUpdate();
|
|
return;
|
|
}
|
|
const target = event.currentTarget as HTMLElement;
|
|
const bounds = target.getBoundingClientRect();
|
|
this.sidebarZoneDropTarget = {
|
|
entry: targetEntry,
|
|
position: event.clientY < bounds.top + bounds.height / 2 ? "before" : "after",
|
|
};
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
handleSidebarZoneDragLeave(event: DragEvent) {
|
|
const current = event.currentTarget as HTMLElement;
|
|
if (event.relatedTarget instanceof Node && current.contains(event.relatedTarget)) {
|
|
return;
|
|
}
|
|
this.sidebarZoneDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
/** Insert `entry` into the freshest canonical order at the captured drop slot. */
|
|
private writeSidebarEntryAt(
|
|
entry: string,
|
|
targetEntry: string | undefined,
|
|
position: "before" | "after" | undefined,
|
|
) {
|
|
const next = this.host
|
|
.reconciledSidebarZone()
|
|
.sidebarEntries.filter((candidate) => candidate !== entry);
|
|
const targetIndex = targetEntry ? next.indexOf(targetEntry) : -1;
|
|
const offset = position === "after" ? 1 : 0;
|
|
next.splice(targetIndex < 0 ? next.length : targetIndex + offset, 0, entry);
|
|
this.host.onUpdateSidebarEntries?.(next);
|
|
}
|
|
|
|
handleSidebarZoneDrop(event: DragEvent, targetEntry?: string) {
|
|
const entry = this.draggedSidebarEntry(event.dataTransfer);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
// Consume before the self-drop bailout: an unhandled drop would bubble to
|
|
// the zone container and append the entry at the end.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (targetEntry === entry) {
|
|
this.finishSidebarEntryDrag();
|
|
return;
|
|
}
|
|
const position = this.sidebarZoneDropTarget?.position;
|
|
const sessionKey = readSessionDragData(event.dataTransfer);
|
|
const session = sessionKey ? this.host.findSidebarSessionByKey(sessionKey) : undefined;
|
|
if (session && !session.pinned) {
|
|
// Persist the dropped slot only once the pin lands, and recompute
|
|
// against the then-current order: a failed patch must not leave an
|
|
// unpinned slot behind, and a stale snapshot must not undo zone edits
|
|
// that raced the request.
|
|
void this.patchSession(session, { pinned: true }).then((result) => {
|
|
if (result === "completed") {
|
|
this.writeSidebarEntryAt(entry, targetEntry, position);
|
|
}
|
|
});
|
|
} else {
|
|
this.writeSidebarEntryAt(entry, targetEntry, position);
|
|
}
|
|
this.finishSidebarEntryDrag();
|
|
}
|
|
|
|
private removeSidebarEntry(entry: string) {
|
|
const next = this.host
|
|
.reconciledSidebarZone()
|
|
.sidebarEntries.filter((candidate) => candidate !== entry);
|
|
this.host.onUpdateSidebarEntries?.(next);
|
|
}
|
|
|
|
handleSessionListDragOver(event: DragEvent) {
|
|
const routeDrag = sidebarRouteDragActive(event.dataTransfer);
|
|
const sessionKey = readSessionDragData(event.dataTransfer);
|
|
const session = sessionKey ? this.host.findSidebarSessionByKey(sessionKey) : undefined;
|
|
if (!routeDrag && !session?.pinned) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = "move";
|
|
}
|
|
this.sessionListRemovalDrop = true;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
handleSessionListDragLeave(event: DragEvent) {
|
|
const current = event.currentTarget as HTMLElement;
|
|
if (!(event.relatedTarget instanceof Node && current.contains(event.relatedTarget))) {
|
|
this.sessionListRemovalDrop = false;
|
|
this.host.requestUpdate();
|
|
}
|
|
}
|
|
|
|
handleSessionListDrop(event: DragEvent) {
|
|
const draggedNavigation = readSidebarRouteDragData(event.dataTransfer);
|
|
const routeEntry = parseSidebarEntry(draggedNavigation ? `route:${draggedNavigation}` : null);
|
|
const dynamicEntry = parseSidebarEntry(draggedNavigation);
|
|
const entry =
|
|
routeEntry?.type === "route"
|
|
? routeEntry
|
|
: dynamicEntry?.type === "workboard"
|
|
? dynamicEntry
|
|
: null;
|
|
if (entry) {
|
|
event.preventDefault();
|
|
this.removeSidebarEntry(serializeSidebarEntry(entry));
|
|
this.finishSidebarEntryDrag();
|
|
return;
|
|
}
|
|
const sessionKey = readSessionDragData(event.dataTransfer);
|
|
const session = sessionKey ? this.host.findSidebarSessionByKey(sessionKey) : undefined;
|
|
if (session?.pinned) {
|
|
event.preventDefault();
|
|
// patchSession prunes the persisted zone entry once the unpin lands.
|
|
void this.patchSession(session, { pinned: false });
|
|
}
|
|
this.finishSidebarEntryDrag();
|
|
}
|
|
|
|
/** A dialog that never opens still owes the operator a visible outcome. */
|
|
private async loadInputDialog(): Promise<InputDialogOpener | null> {
|
|
try {
|
|
return (await import("./input-dialog.ts")).showInputDialog;
|
|
} catch (error) {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (scope) {
|
|
this.host.sessionData.publishSessionMutationError(scope, error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async renameSession(session: SidebarRecentSession): Promise<void> {
|
|
const showInputDialog = await this.loadInputDialog();
|
|
const nextLabel =
|
|
(await showInputDialog?.({
|
|
title: t("sessionsView.renameSessionPrompt"),
|
|
// The stored label, not the resolved display name: pre-filling the
|
|
// derived string persists it on submit and it then outranks every
|
|
// later derivation. Matches the Sessions page rename.
|
|
defaultValue: normalizeOptionalString(session.userLabel) ?? "",
|
|
})) ?? null;
|
|
if (nextLabel === null) {
|
|
return;
|
|
}
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.renameSession(this.host, session, nextLabel, scope);
|
|
}
|
|
|
|
async createSessionGroup(sessions: readonly SidebarRecentSession[] = []): Promise<void> {
|
|
const showInputDialog = await this.loadInputDialog();
|
|
await showInputDialog?.({
|
|
title: t("sessionsView.newGroupTitle"),
|
|
label: t("sessionsView.newGroupPrompt"),
|
|
submitLabel: t("sessionsView.newGroupCreate"),
|
|
requireValue: true,
|
|
submit: (name) => this.writeSessionGroup(name, sessions),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replays the failure the mutation already recorded so the dialog can keep the
|
|
* typed name for a retry. A replaced connection confirmed neither the group nor
|
|
* the move, so it reports a retryable message too rather than closing on an
|
|
* outcome that never landed; resubmitting runs against the new connection.
|
|
*/
|
|
private async writeSessionGroup(
|
|
name: string,
|
|
sessions: readonly SidebarRecentSession[],
|
|
): Promise<string | null> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return t("sessionsView.newGroupFailed");
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
if (!operations) {
|
|
return this.host.sessionData.isSessionMutationScopeCurrent(scope)
|
|
? this.sessionGroupFailure()
|
|
: t("sessionsView.newGroupStale");
|
|
}
|
|
const result = await operations.createSessionGroup(this.host, name, sessions, scope);
|
|
if (result === "failed") {
|
|
return this.sessionGroupFailure();
|
|
}
|
|
return result === "stale" ? t("sessionsView.newGroupStale") : null;
|
|
}
|
|
|
|
private sessionGroupFailure(): string {
|
|
return this.host.sessionData.sessionMutationError ?? t("sessionsView.newGroupFailed");
|
|
}
|
|
|
|
async renameSessionGroupFromMenu(group: string): Promise<void> {
|
|
const showInputDialog = await this.loadInputDialog();
|
|
// requireChange holds the submit closed on the name the group already has,
|
|
// so the only rename that reaches the Gateway is one that changes something.
|
|
const next = await showInputDialog?.({
|
|
title: t("sessionsView.renameGroupTitle", { group }),
|
|
label: t("sessionsView.groupNameLabel"),
|
|
defaultValue: group,
|
|
requireValue: true,
|
|
requireChange: true,
|
|
});
|
|
if (!next) {
|
|
return;
|
|
}
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
if (!operations || !(await operations.renameSessionGroup(this.host, group, next, scope))) {
|
|
return;
|
|
}
|
|
// Collapse keys follow only a confirmed Gateway rename. A stale completion
|
|
// must not rewrite storage owned by the replacement connection.
|
|
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.host.requestUpdate();
|
|
}
|
|
|
|
async deleteSessionGroupFromMenu(group: string): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
if (!operations || !(await operations.deleteSessionGroup(this.host, group, scope))) {
|
|
return;
|
|
}
|
|
const collapsed = new Set(this.collapsedSessionSections);
|
|
collapsed.delete(`category:${group}`);
|
|
this.saveCollapsedSessionSections(collapsed);
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
async editSessionGroupDefaults(group: string): Promise<void> {
|
|
let showDialog: SessionGroupDefaultsDialogOpener;
|
|
try {
|
|
showDialog = (await import("./session-group-defaults-dialog.ts"))
|
|
.showSessionGroupDefaultsDialog;
|
|
} catch (error) {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (scope) {
|
|
this.host.sessionData.publishSessionMutationError(scope, error);
|
|
}
|
|
return;
|
|
}
|
|
const defaults = this.host.sessionGroupDefaults(group);
|
|
if (defaults) {
|
|
await showDialog({
|
|
group,
|
|
defaults,
|
|
listDirectory: (path) => this.host.listSessionGroupFolders(path),
|
|
inspectRepository: (path) => this.host.inspectSessionGroupRepository(path),
|
|
submit: async (nextDefaults) => {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope || !this.host.sessionGroupDefaults(group)) {
|
|
return t("sessionsView.groupDefaultsStale");
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
const result = await operations?.updateSessionGroupDefaults(
|
|
this.host,
|
|
group,
|
|
{ cwd: nextDefaults.cwd || null, worktree: nextDefaults.worktree },
|
|
scope,
|
|
);
|
|
return result === "completed"
|
|
? null
|
|
: result === "stale"
|
|
? t("sessionsView.groupDefaultsStale")
|
|
: (this.host.sessionData.sessionMutationError ??
|
|
t("sessionsView.groupDefaultsFailed"));
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
saveCollapsedSessionSections(sections: ReadonlySet<string>) {
|
|
this.collapsedSessionSections = new Set(sections);
|
|
this.host.requestUpdate();
|
|
try {
|
|
storeCollapsedSessionSections(sections);
|
|
} catch {
|
|
// Group membership and ordering remain usable without local persistence.
|
|
}
|
|
}
|
|
|
|
toggleSection(sectionId: string) {
|
|
const collapsed = new Set(this.collapsedSessionSections);
|
|
if (collapsed.has(sectionId)) {
|
|
collapsed.delete(sectionId);
|
|
} else {
|
|
collapsed.add(sectionId);
|
|
}
|
|
this.saveCollapsedSessionSections(collapsed);
|
|
}
|
|
|
|
private async reorderSidebarSection(
|
|
sourceSectionId: string,
|
|
targetSectionId: string,
|
|
position: "before" | "after",
|
|
): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.reorderSidebarSection(
|
|
this.host,
|
|
sourceSectionId,
|
|
targetSectionId,
|
|
position,
|
|
scope,
|
|
);
|
|
}
|
|
|
|
async assignSessionCategory(
|
|
session: SidebarRecentSession,
|
|
category: string | null,
|
|
patch: { pinned?: boolean } = {},
|
|
): Promise<void> {
|
|
const scope = this.host.sessionData.beginSessionMutation();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const operations = await this.loadOperations(scope);
|
|
await operations?.assignSessionCategory(this.host, session, category, scope, patch);
|
|
}
|
|
|
|
private sectionAcceptsSession(
|
|
sectionId: string,
|
|
category: string | undefined,
|
|
session: SidebarRecentSession | undefined,
|
|
): boolean {
|
|
if (sectionId === "pinned") {
|
|
return true;
|
|
}
|
|
if (
|
|
this.host.sessionsGrouping === "category" &&
|
|
(sectionId === "ungrouped" || Boolean(category))
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
sectionId === "groups" &&
|
|
Boolean(session && categoryClearReturnsToGroups(session, this.host.sessionsGrouping))
|
|
);
|
|
}
|
|
|
|
sectionDragOver(event: DragEvent, sectionId: string, category?: string) {
|
|
const dataTransfer = event.dataTransfer;
|
|
if (sidebarSectionDragActive(dataTransfer) && this.draggingSidebarSection !== sectionId) {
|
|
event.preventDefault();
|
|
if (dataTransfer) {
|
|
dataTransfer.dropEffect = "move";
|
|
}
|
|
const target = event.currentTarget as HTMLElement;
|
|
const header = target.querySelector<HTMLElement>(":scope > .sidebar-recent-sessions__head");
|
|
const bounds = (header ?? target).getBoundingClientRect();
|
|
const position = event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
|
this.sidebarSectionDropTarget = { sectionId, position };
|
|
this.host.requestUpdate();
|
|
this.sessionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
return;
|
|
}
|
|
if (!sessionDragActive(dataTransfer)) {
|
|
return;
|
|
}
|
|
// Browsers protect transferred data during dragover. Use the key recorded
|
|
// at dragstart for hover eligibility; sectionDrop reads the payload itself.
|
|
const session = this.draggingSessionKey
|
|
? this.host.findSidebarSessionByKey(this.draggingSessionKey)
|
|
: undefined;
|
|
if (!this.sectionAcceptsSession(sectionId, category, session)) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (dataTransfer) {
|
|
dataTransfer.dropEffect = "move";
|
|
}
|
|
this.sessionDropTarget = sectionId;
|
|
this.host.requestUpdate();
|
|
this.sidebarSectionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
sectionDragLeave(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;
|
|
this.host.requestUpdate();
|
|
}
|
|
if (this.sidebarSectionDropTarget?.sectionId === sectionId) {
|
|
this.sidebarSectionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
}
|
|
|
|
sectionDrop(event: DragEvent, sectionId: string, category?: string) {
|
|
const sourceSectionId = readSidebarSectionDragData(event.dataTransfer);
|
|
const sessionKey = readSessionDragData(event.dataTransfer);
|
|
if (!sourceSectionId && !sessionKey) {
|
|
return;
|
|
}
|
|
// Rows can be dragged from a browsed agent section, so search all caches.
|
|
const session = sessionKey ? this.host.findSidebarSessionByKey(sessionKey) : undefined;
|
|
if (!sourceSectionId && !this.sectionAcceptsSession(sectionId, category, session)) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (sourceSectionId && sourceSectionId !== sectionId) {
|
|
const position =
|
|
this.sidebarSectionDropTarget?.sectionId === sectionId
|
|
? this.sidebarSectionDropTarget.position
|
|
: "before";
|
|
void this.reorderSidebarSection(sourceSectionId, sectionId, position);
|
|
} else if (session && sectionId === "pinned") {
|
|
if (!session.pinned) {
|
|
void this.patchSession(session, { pinned: true });
|
|
}
|
|
} else if (session) {
|
|
const nextCategory = category ?? null;
|
|
if (session.category !== nextCategory || session.pinned) {
|
|
// The pinned:false leg prunes the persisted zone entry via patchSession.
|
|
void this.assignSessionCategory(
|
|
session,
|
|
nextCategory,
|
|
session.pinned ? { pinned: false } : {},
|
|
);
|
|
}
|
|
}
|
|
this.finishSidebarEntryDrag();
|
|
this.draggingSidebarSection = null;
|
|
this.host.requestUpdate();
|
|
this.sessionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
this.sidebarSectionDropTarget = null;
|
|
this.host.requestUpdate();
|
|
}
|
|
|
|
setSessionsGrouping(grouping: SidebarSessionsGrouping) {
|
|
this.host.sessionsGrouping = grouping;
|
|
try {
|
|
storeSidebarSessionsGrouping(grouping);
|
|
} catch {
|
|
// Keep the in-memory preference when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
setSessionsShowCron(show: boolean) {
|
|
this.host.sessionsShowCron = show;
|
|
try {
|
|
storeSidebarSessionsShowCron(show);
|
|
} catch {
|
|
// Keep the in-memory preference when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
setSessionsShowPreview(show: boolean) {
|
|
this.host.sessionsShowPreview = show;
|
|
try {
|
|
storeSidebarSessionsShowPreview(show);
|
|
} catch {
|
|
// Keep the in-memory preference when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
setSessionsShowSystem(show: boolean) {
|
|
this.host.sessionsShowSystem = show;
|
|
try {
|
|
storeSidebarSessionsShowSystem(show);
|
|
} catch {
|
|
// Keep the in-memory preference when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
setSessionsStatusFilter(statusFilter: SidebarSessionStatusFilter) {
|
|
if (statusFilter === this.host.sessionsStatusFilter) {
|
|
return;
|
|
}
|
|
this.host.sessionsStatusFilter = statusFilter;
|
|
this.host.clearSessionSelection();
|
|
this.host.sessionData.resetForStatusFilter(statusFilter);
|
|
try {
|
|
storeSidebarSessionStatusFilter(statusFilter);
|
|
} catch {
|
|
// Keep the in-memory preference when storage is unavailable.
|
|
}
|
|
void this.host.sessionData.refreshSidebarSessions();
|
|
}
|
|
}
|