Skip to content

Commit ed43a1e

Browse files
committed
feat(ui): session unarchive flows with undo toast
Archiving from the web UI was silent and one-way. Adds a shared single-slot toast with Undo (restores archive state, pin, and active selection), a sidebar 'View archived' entry into Settings -> Sessions, an Active|Archived segment replacing the buried archived-only chip, an inline Restore button on archived chats' disabled composer, and a guarded 'Delete all archived' bulk action that fully re-enumerates archived sessions (paginated, aborts on any abnormal page) and passes the protocol archivedOnly guard. No gateway/protocol changes.
1 parent e8734d8 commit ed43a1e

21 files changed

Lines changed: 847 additions & 54 deletions

ui/src/app/app-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
4848
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
4949
import { searchForSession } from "../lib/sessions/index.ts";
5050
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
51+
import "../lib/toast.ts";
5152
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
5253
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
5354
import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts";
@@ -1453,6 +1454,7 @@ class OpenClawShell extends OpenClawLightDomElement {
14531454
.context=${context}
14541455
></openclaw-onboarding-memory-import>`
14551456
: nothing}
1457+
<openclaw-toast-host></openclaw-toast-host>
14561458
</div>
14571459
`;
14581460
}

ui/src/components/app-sidebar-menus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
464464
this.createSessionGroup([session]);
465465
break;
466466
case "toggle-archived":
467-
void this.patchSession(session, { archived: true });
467+
void this.archiveSessionWithUndo(session);
468468
break;
469469
case "stop-cloud-worker":
470470
void this.stopCloudWorker(session);

ui/src/components/app-sidebar-session-list.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,13 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
560560
codingTrailing: html`${this.renderSessionCatalogs(navigationState)}`,
561561
codingTrailingPresent: this.sessionCatalogs.length > 0,
562562
})}
563+
<button
564+
type="button"
565+
class="sidebar-view-archived"
566+
@click=${() => this.onNavigate?.("sessions", { search: "?showArchived=1" })}
567+
>
568+
${icons.archive} ${t("sessionsView.viewArchived")}
569+
</button>
563570
</div>
564571
</section>
565572
`;

ui/src/components/app-sidebar-session-mutations.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
parseAgentSessionKey,
55
resolveUiConfiguredMainKey,
66
} from "../lib/sessions/session-key.ts";
7+
import { showToast } from "../lib/toast.ts";
78
import { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts";
89
import type {
910
SidebarRecentSession,
@@ -80,6 +81,78 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
8081
return result;
8182
}
8283

84+
protected async archiveSessionWithUndo(session: SidebarRecentSession) {
85+
const scope = this.beginSessionMutation();
86+
if (!scope) {
87+
return;
88+
}
89+
const result = await this.patchSession(session, { archived: true }, scope);
90+
if (result !== "completed" || !this.isSessionMutationScopeCurrent(scope)) {
91+
return;
92+
}
93+
showToast({
94+
message: t("sessionsView.sessionArchived"),
95+
actionLabel: t("common.undo"),
96+
onAction: () => {
97+
void this.restoreArchivedSessions([{ session, pinned: session.pinned }], scope);
98+
},
99+
});
100+
}
101+
102+
private async archiveSessionsWithUndo(rows: readonly SidebarRecentSession[]) {
103+
const scope = this.beginSessionMutation();
104+
if (!scope) {
105+
return;
106+
}
107+
const archived: Array<{ session: SidebarRecentSession; pinned: boolean }> = [];
108+
for (const session of rows) {
109+
const result = await this.patchSession(session, { archived: true }, scope);
110+
if (result === "stale") {
111+
return;
112+
}
113+
if (result === "completed") {
114+
archived.push({ session, pinned: session.pinned });
115+
}
116+
}
117+
if (archived.length === 0 || !this.isSessionMutationScopeCurrent(scope)) {
118+
return;
119+
}
120+
showToast({
121+
message:
122+
archived.length === 1
123+
? t("sessionsView.sessionArchived")
124+
: t("sessionsView.sessionsArchived", { count: String(archived.length) }),
125+
actionLabel: t("common.undo"),
126+
onAction: () => void this.restoreArchivedSessions(archived, scope),
127+
});
128+
}
129+
130+
private async restoreArchivedSessions(
131+
archived: readonly { session: SidebarRecentSession; pinned: boolean }[],
132+
scope: SidebarSessionMutationScope,
133+
) {
134+
if (!this.isSessionMutationScopeCurrent(scope)) {
135+
return;
136+
}
137+
let restoredActiveKey: string | null = null;
138+
for (const { session, pinned } of archived) {
139+
const result = await this.patchSession(
140+
session,
141+
{ archived: false, ...(pinned ? { pinned: true } : {}) },
142+
scope,
143+
);
144+
if (result === "stale") {
145+
return;
146+
}
147+
if (result === "completed" && session.active) {
148+
restoredActiveKey = session.key;
149+
}
150+
}
151+
if (restoredActiveKey && this.isSessionMutationScopeCurrent(scope)) {
152+
this.replaceCurrentSession(restoredActiveKey);
153+
}
154+
}
155+
83156
/** One confirm and one preserved-worktrees alert for the whole selection. */
84157
protected async deleteSessionsBatch(rows: readonly SidebarRecentSession[]) {
85158
if (rows.length === 0) {
@@ -153,7 +226,7 @@ export abstract class AppSidebarSessionMutationsElement extends AppSidebarSessio
153226
this.createSessionGroup(rows);
154227
break;
155228
case "toggle-archived":
156-
void this.patchSessions(rows, { archived: true });
229+
void this.archiveSessionsWithUndo(rows);
157230
break;
158231
case "delete":
159232
void this.deleteSessionsBatch(rows);

ui/src/components/app-sidebar.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,12 @@ function createGatewayHarness(client: GatewayBrowserClient) {
8181
};
8282
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
8383
const eventListeners = new Set<(event: { event: string; payload: unknown }) => void>();
84+
const setSessionKey = vi.fn();
8485
const gateway = {
8586
get snapshot() {
8687
return snapshot;
8788
},
88-
setSessionKey: () => undefined,
89+
setSessionKey,
8990
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
9091
listeners.add(listener);
9192
return () => listeners.delete(listener);
@@ -97,6 +98,7 @@ function createGatewayHarness(client: GatewayBrowserClient) {
9798
} as unknown as ApplicationGateway;
9899
return {
99100
gateway,
101+
setSessionKey,
100102
publish(patch: Partial<ApplicationGatewaySnapshot>) {
101103
snapshot = { ...snapshot, ...patch };
102104
for (const listener of listeners) {
@@ -367,6 +369,21 @@ describe("AppSidebar brand actions", () => {
367369
button?.click();
368370
expect(onOpenNewSession).toHaveBeenCalledExactlyOnceWith("research");
369371
});
372+
373+
it("opens the archived Sessions view from the sessions-zone footer", async () => {
374+
const gateway = createGateway({} as GatewayBrowserClient);
375+
const { sidebar } = await mountSidebar(
376+
gateway,
377+
createSessions("main", ["agent:main:main", "agent:main:work"]),
378+
);
379+
const onNavigate = vi.fn();
380+
sidebar.onNavigate = onNavigate;
381+
await sidebar.updateComplete;
382+
383+
sidebar.querySelector<HTMLButtonElement>(".sidebar-view-archived")?.click();
384+
385+
expect(onNavigate).toHaveBeenCalledWith("sessions", { search: "?showArchived=1" });
386+
});
370387
});
371388

372389
describe("AppSidebar agent chip", () => {
@@ -3547,6 +3564,55 @@ describe("AppSidebar session mutation feedback", () => {
35473564
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }));
35483565
}
35493566

3567+
async function mountToastHost() {
3568+
const host = document.createElement("openclaw-toast-host");
3569+
document.body.append(host);
3570+
await host.updateComplete;
3571+
return host;
3572+
}
3573+
3574+
it("offers undo after archiving and restores a pinned active session", async () => {
3575+
const { gateway, harness, sidebar } = await mountMutationHarness();
3576+
const state = createSessionState("main", ["agent:main:main", "agent:main:a", "agent:main:b"]);
3577+
const archivedRow = state.result?.sessions.find((row) => row.key === "agent:main:a");
3578+
if (!archivedRow) {
3579+
throw new Error("expected archive row");
3580+
}
3581+
archivedRow.pinned = true;
3582+
harness.publishList({ result: state.result, agentId: state.agentId });
3583+
gateway.publish({ sessionKey: archivedRow.key });
3584+
sidebar.sessionKey = archivedRow.key;
3585+
(sidebar as unknown as { activeRouteId: string }).activeRouteId = "chat";
3586+
const navigate = vi.fn();
3587+
sidebar.onNavigate = navigate;
3588+
const toast = await mountToastHost();
3589+
await sidebar.updateComplete;
3590+
3591+
const menu = await openSessionMenu(sidebar, archivedRow.key);
3592+
menu.querySelector<HTMLButtonElement>('[data-shortcut="a"]')?.click();
3593+
await vi.waitFor(() => expect(harness.patch).toHaveBeenCalledOnce());
3594+
await vi.waitFor(() =>
3595+
expect(toast.querySelector(".app-toast__message")?.textContent).toBe("Session archived"),
3596+
);
3597+
expect(harness.patch).toHaveBeenCalledWith(
3598+
archivedRow.key,
3599+
{ archived: true },
3600+
{ agentId: "main" },
3601+
);
3602+
toast.querySelector<HTMLButtonElement>(".app-toast__action")?.click();
3603+
3604+
await vi.waitFor(() => expect(harness.patch).toHaveBeenCalledTimes(2));
3605+
await vi.waitFor(() => expect(gateway.setSessionKey).toHaveBeenLastCalledWith(archivedRow.key));
3606+
expect(harness.patch).toHaveBeenLastCalledWith(
3607+
archivedRow.key,
3608+
{ archived: false, pinned: true },
3609+
{ agentId: "main" },
3610+
);
3611+
expect(navigate).toHaveBeenLastCalledWith("chat", {
3612+
search: "?session=agent%3Amain%3Aa",
3613+
});
3614+
});
3615+
35503616
it("reconciles and stops an idle active cloud worker through its session", async () => {
35513617
const request = vi.fn(() => Promise.resolve({ ok: true }));
35523618
const { gateway, harness, sidebar } = await mountMutationHarness({

ui/src/i18n/locales/en.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const en: TranslationMap = {
2020
staleData: "Showing stale data.",
2121
reload: "Reload",
2222
reset: "Reset",
23+
restore: "Restore",
24+
undo: "Undo",
2325
probe: "Probe",
2426
call: "Call",
2527
confirm: "Confirm",
@@ -524,7 +526,12 @@ export const en: TranslationMap = {
524526
sourceFilters: "Session source filters",
525527
global: "Global",
526528
unknown: "Unknown",
527-
archivedOnly: "Archived only",
529+
sessionState: "Session state",
530+
viewArchived: "View archived",
531+
sessionArchived: "Session archived",
532+
sessionsArchived: "Archived {count} sessions",
533+
deleteAllArchived: "Delete all archived…",
534+
deleteAllArchivedConfirm: "Delete {count} archived sessions and their transcripts?",
528535
activeTooltip: "Loads sessions updated in the last {count} minutes.",
529536
limitTooltip: "Max sessions to load.",
530537
globalTooltip: "Include global sessions.",

ui/src/lib/sessions/index.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -384,14 +384,17 @@ describe("createSessionCapability", () => {
384384
deletedSnapshots.push(next.deletedSessions.map((target) => target.key));
385385
});
386386

387-
await expect(sessions.deleteMany([{ key: keptKey }, { key: deletedKey }])).resolves.toEqual({
388-
deleted: [deletedKey],
389-
errors: [],
390-
preservedWorktrees: [],
391-
});
387+
await expect(
388+
sessions.deleteMany([{ key: keptKey }, { key: deletedKey, archivedOnly: true }]),
389+
).resolves.toEqual({ deleted: [deletedKey], errors: [], preservedWorktrees: [] });
392390
expect(deletedSnapshots.some((keys) => keys.includes(deletedKey))).toBe(true);
393391
expect(deletedSnapshots.some((keys) => keys.includes(keptKey))).toBe(false);
394392
expect(request).toHaveBeenCalledTimes(3);
393+
expect(request).toHaveBeenCalledWith("sessions.delete", {
394+
key: deletedKey,
395+
deleteTranscript: true,
396+
archivedOnly: true,
397+
});
395398
unsubscribe();
396399
sessions.dispose();
397400
});

ui/src/lib/sessions/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ export type { SessionPatch } from "./patch.ts";
9898
type SessionDeleteOptions = {
9999
agentId?: string;
100100
deleteTranscript?: boolean;
101+
archivedOnly?: boolean;
101102
};
102103

103104
type SessionDeleteTarget = {
104105
key: string;
105106
agentId?: string;
106107
deleteTranscript?: boolean;
108+
archivedOnly?: boolean;
107109
};
108110

109111
/** Dirty/unpushed checkouts survive session deletion; callers surface them. */
@@ -361,6 +363,7 @@ function requestSessionDelete(
361363
return client.request<SessionDeleteResponse>("sessions.delete", {
362364
...buildSessionRequestParams(key, options.agentId),
363365
deleteTranscript: options.deleteTranscript ?? true,
366+
...(options.archivedOnly === true ? { archivedOnly: true } : {}),
364367
});
365368
}
366369

ui/src/lib/toast.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/* @vitest-environment jsdom */
2+
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { showToast } from "./toast.ts";
5+
6+
async function mountHost() {
7+
const host = document.createElement("openclaw-toast-host");
8+
document.body.append(host);
9+
await host.updateComplete;
10+
return host;
11+
}
12+
13+
afterEach(() => {
14+
document.body.replaceChildren();
15+
vi.useRealTimers();
16+
});
17+
18+
describe("shared toast", () => {
19+
it("shows and replaces the active toast", async () => {
20+
const host = await mountHost();
21+
22+
showToast({ message: "First" });
23+
await host.updateComplete;
24+
expect(host.querySelector(".app-toast__message")?.textContent).toBe("First");
25+
26+
showToast({ message: "Second" });
27+
await host.updateComplete;
28+
expect(host.querySelectorAll(".app-toast")).toHaveLength(1);
29+
expect(host.querySelector(".app-toast__message")?.textContent).toBe("Second");
30+
});
31+
32+
it("auto-dismisses after the configured duration", async () => {
33+
vi.useFakeTimers();
34+
const host = await mountHost();
35+
36+
showToast({ message: "Temporary", durationMs: 50 });
37+
await host.updateComplete;
38+
await vi.advanceTimersByTimeAsync(50);
39+
await host.updateComplete;
40+
41+
expect(host.querySelector(".app-toast")).toBeNull();
42+
});
43+
44+
it("runs its action once and dismisses", async () => {
45+
const host = await mountHost();
46+
const onAction = vi.fn();
47+
showToast({ message: "Archived", actionLabel: "Undo", onAction });
48+
await host.updateComplete;
49+
50+
host.querySelector<HTMLButtonElement>(".app-toast__action")?.click();
51+
await host.updateComplete;
52+
53+
expect(onAction).toHaveBeenCalledOnce();
54+
expect(host.querySelector(".app-toast")).toBeNull();
55+
});
56+
});

0 commit comments

Comments
 (0)