-
-
- ${group.tabs.map((tab) => renderTab(state, tab))}
+
- `;
- })}
-
-
- ${t("common.resources")}
-
-
@@ -225,6 +323,15 @@ export function renderApp(state: AppViewState) {
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
+ usageResult: state.usageResult,
+ sessionsResult: state.sessionsResult,
+ skillsReport: state.skillsReport,
+ cronJobs: state.cronJobs,
+ cronStatus: state.cronStatus,
+ attentionItems: state.attentionItems,
+ eventLog: state.eventLog,
+ overviewLogLines: state.overviewLogLines,
+ streamMode: state.streamMode,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
@@ -240,6 +347,16 @@ export function renderApp(state: AppViewState) {
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
+ onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
+ onRefreshLogs: () => state.loadOverview(),
+ onToggleStreamMode: () => {
+ state.streamMode = !state.streamMode;
+ try {
+ localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
+ } catch {
+ /* */
+ }
+ },
})
: nothing
}
@@ -290,6 +407,7 @@ export function renderApp(state: AppViewState) {
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
+ streamMode: state.streamMode,
onRefresh: () => loadPresence(state),
})
: nothing
@@ -358,33 +476,47 @@ export function renderApp(state: AppViewState) {
agentsList: state.agentsList,
selectedAgentId: resolvedAgentId,
activePanel: state.agentsPanel,
- configForm: configValue,
- configLoading: state.configLoading,
- configSaving: state.configSaving,
- configDirty: state.configFormDirty,
- channelsLoading: state.channelsLoading,
- channelsError: state.channelsError,
- channelsSnapshot: state.channelsSnapshot,
- channelsLastSuccess: state.channelsLastSuccess,
- cronLoading: state.cronLoading,
- cronStatus: state.cronStatus,
- cronJobs: state.cronJobs,
- cronError: state.cronError,
- agentFilesLoading: state.agentFilesLoading,
- agentFilesError: state.agentFilesError,
- agentFilesList: state.agentFilesList,
- agentFileActive: state.agentFileActive,
- agentFileContents: state.agentFileContents,
- agentFileDrafts: state.agentFileDrafts,
- agentFileSaving: state.agentFileSaving,
+ config: {
+ form: configValue,
+ loading: state.configLoading,
+ saving: state.configSaving,
+ dirty: state.configFormDirty,
+ },
+ channels: {
+ snapshot: state.channelsSnapshot,
+ loading: state.channelsLoading,
+ error: state.channelsError,
+ lastSuccess: state.channelsLastSuccess,
+ },
+ cron: {
+ status: state.cronStatus,
+ jobs: state.cronJobs,
+ loading: state.cronLoading,
+ error: state.cronError,
+ },
+ agentFiles: {
+ list: state.agentFilesList,
+ loading: state.agentFilesLoading,
+ error: state.agentFilesError,
+ active: state.agentFileActive,
+ contents: state.agentFileContents,
+ drafts: state.agentFileDrafts,
+ saving: state.agentFileSaving,
+ },
agentIdentityLoading: state.agentIdentityLoading,
agentIdentityError: state.agentIdentityError,
agentIdentityById: state.agentIdentityById,
- agentSkillsLoading: state.agentSkillsLoading,
- agentSkillsReport: state.agentSkillsReport,
- agentSkillsError: state.agentSkillsError,
- agentSkillsAgentId: state.agentSkillsAgentId,
- skillsFilter: state.skillsFilter,
+ agentSkills: {
+ report: state.agentSkillsReport,
+ loading: state.agentSkillsLoading,
+ error: state.agentSkillsError,
+ agentId: state.agentSkillsAgentId,
+ filter: state.skillsFilter,
+ },
+ sidebarFilter: state.agentsSidebarFilter,
+ onSidebarFilterChange: (value) => {
+ state.agentsSidebarFilter = value;
+ },
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
@@ -523,6 +655,9 @@ export function renderApp(state: AppViewState) {
onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
+ onCronRunNow: (_jobId) => {
+ // Stub: backend support pending
+ },
onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => {
if (resolvedAgentId) {
@@ -692,6 +827,12 @@ export function renderApp(state: AppViewState) {
: { fallbacks: normalized };
updateConfigFormValue(state, basePath, next);
},
+ onSetDefault: (agentId) => {
+ if (!configValue) {
+ return;
+ }
+ updateConfigFormValue(state, ["agents", "defaultId"], agentId);
+ },
})
: nothing
}
@@ -860,6 +1001,45 @@ export function renderApp(state: AppViewState) {
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
+ onClearHistory: async () => {
+ if (!state.client || !state.connected) {
+ return;
+ }
+ try {
+ await state.client.request("sessions.reset", { key: state.sessionKey });
+ state.chatMessages = [];
+ state.chatStream = null;
+ state.chatRunId = null;
+ await loadChatHistory(state);
+ } catch (err) {
+ state.lastError = String(err);
+ }
+ },
+ agentsList: state.agentsList,
+ currentAgentId: resolvedAgentId ?? "main",
+ onAgentChange: (agentId: string) => {
+ state.sessionKey = buildAgentMainSessionKey({ agentId });
+ state.chatMessages = [];
+ state.chatStream = null;
+ state.chatRunId = null;
+ state.applySettings({
+ ...state.settings,
+ sessionKey: state.sessionKey,
+ lastActiveSessionKey: state.sessionKey,
+ });
+ void loadChatHistory(state);
+ void state.loadAssistantIdentity();
+ },
+ onNavigateToAgent: () => {
+ state.agentsSelectedId = resolvedAgentId;
+ state.setTab("agents" as import("./navigation.ts").Tab);
+ },
+ onSessionSelect: (key: string) => {
+ state.setSessionKey(key);
+ state.chatMessages = [];
+ void loadChatHistory(state);
+ void state.loadAssistantIdentity();
+ },
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
@@ -897,6 +1077,7 @@ export function renderApp(state: AppViewState) {
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
+ streamMode: state.streamMode,
onRawChange: (next) => {
state.configRaw = next;
},
@@ -962,6 +1143,10 @@ export function renderApp(state: AppViewState) {
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
+ ${renderBottomTabs({
+ activeTab: state.tab,
+ onTabChange: (tab) => state.setTab(tab),
+ })}
`;
}
diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts
index 48411bbe5b0..e1b05791306 100644
--- a/ui/src/ui/app-settings.test.ts
+++ b/ui/src/ui/app-settings.test.ts
@@ -13,14 +13,14 @@ const createHost = (tab: Tab): SettingsHost => ({
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
- theme: "system",
+ theme: "dark",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
- theme: "system",
+ theme: "dark",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
@@ -31,8 +31,6 @@ const createHost = (tab: Tab): SettingsHost => ({
eventLog: [],
eventLogBuffer: [],
basePath: "",
- themeMedia: null,
- themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
});
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index 7415e468e0b..1d50cd9852c 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -21,6 +21,7 @@ import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { loadSkills } from "./controllers/skills.ts";
+import { loadUsage } from "./controllers/usage.ts";
import {
inferBasePathFromPathname,
normalizeBasePath,
@@ -32,7 +33,7 @@ import {
import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
-import type { AgentsListResult } from "./types.ts";
+import type { AgentsListResult, AttentionItem } from "./types.ts";
type SettingsHost = {
settings: UiSettings;
@@ -51,8 +52,6 @@ type SettingsHost = {
agentsList?: AgentsListResult | null;
agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
- themeMedia: MediaQueryList | null;
- themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
};
@@ -259,7 +258,7 @@ export function inferBasePath() {
}
export function syncThemeWithSettings(host: SettingsHost) {
- host.theme = host.settings.theme ?? "system";
+ host.theme = host.settings.theme ?? "dark";
applyResolvedTheme(host, resolveTheme(host.theme));
}
@@ -270,44 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
}
const root = document.documentElement;
root.dataset.theme = resolved;
- root.style.colorScheme = resolved;
-}
-
-export function attachThemeListener(host: SettingsHost) {
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
- return;
- }
- host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
- host.themeMediaHandler = (event) => {
- if (host.theme !== "system") {
- return;
- }
- applyResolvedTheme(host, event.matches ? "dark" : "light");
- };
- if (typeof host.themeMedia.addEventListener === "function") {
- host.themeMedia.addEventListener("change", host.themeMediaHandler);
- return;
- }
- const legacy = host.themeMedia as MediaQueryList & {
- addListener: (cb: (event: MediaQueryListEvent) => void) => void;
- };
- legacy.addListener(host.themeMediaHandler);
-}
-
-export function detachThemeListener(host: SettingsHost) {
- if (!host.themeMedia || !host.themeMediaHandler) {
- return;
- }
- if (typeof host.themeMedia.removeEventListener === "function") {
- host.themeMedia.removeEventListener("change", host.themeMediaHandler);
- return;
- }
- const legacy = host.themeMedia as MediaQueryList & {
- removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
- };
- legacy.removeListener(host.themeMediaHandler);
- host.themeMedia = null;
- host.themeMediaHandler = null;
+ root.style.colorScheme = "dark";
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@@ -403,13 +365,121 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
}
export async function loadOverview(host: SettingsHost) {
- await Promise.all([
- loadChannels(host as unknown as OpenClawApp, false),
- loadPresence(host as unknown as OpenClawApp),
- loadSessions(host as unknown as OpenClawApp),
- loadCronStatus(host as unknown as OpenClawApp),
- loadDebug(host as unknown as OpenClawApp),
+ const app = host as unknown as OpenClawApp;
+ await Promise.allSettled([
+ loadChannels(app, false),
+ loadPresence(app),
+ loadSessions(app),
+ loadCronStatus(app),
+ loadCronJobs(app),
+ loadDebug(app),
+ loadSkills(app),
+ loadUsage(app),
+ loadOverviewLogs(app),
]);
+ buildAttentionItems(app);
+}
+
+async function loadOverviewLogs(host: OpenClawApp) {
+ if (!host.client || !host.connected) {
+ return;
+ }
+ try {
+ const res = await host.client.request("logs.tail", {
+ cursor: host.overviewLogCursor || undefined,
+ limit: 100,
+ maxBytes: 50_000,
+ });
+ const payload = res as {
+ cursor?: number;
+ lines?: unknown;
+ };
+ const lines = Array.isArray(payload.lines)
+ ? payload.lines.filter((line): line is string => typeof line === "string")
+ : [];
+ host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500);
+ if (typeof payload.cursor === "number") {
+ host.overviewLogCursor = payload.cursor;
+ }
+ } catch {
+ /* non-critical */
+ }
+}
+
+function buildAttentionItems(host: OpenClawApp) {
+ const items: AttentionItem[] = [];
+
+ if (host.lastError) {
+ items.push({
+ severity: "error",
+ icon: "x",
+ title: "Gateway Error",
+ description: host.lastError,
+ });
+ }
+
+ const hello = host.hello;
+ const auth = (hello as { auth?: { scopes?: string[] } } | null)?.auth;
+ if (auth?.scopes && !auth.scopes.includes("operator.read")) {
+ items.push({
+ severity: "warning",
+ icon: "key",
+ title: "Missing operator.read scope",
+ description:
+ "This connection does not have the operator.read scope. Some features may be unavailable.",
+ href: "https://docs.openclaw.ai/web/dashboard",
+ external: true,
+ });
+ }
+
+ const skills = host.skillsReport?.skills ?? [];
+ const missingDeps = skills.filter((s) => !s.disabled && Object.keys(s.missing).length > 0);
+ if (missingDeps.length > 0) {
+ const names = missingDeps.slice(0, 3).map((s) => s.name);
+ const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : "";
+ items.push({
+ severity: "warning",
+ icon: "zap",
+ title: "Skills with missing dependencies",
+ description: `${names.join(", ")}${more}`,
+ });
+ }
+
+ const blocked = skills.filter((s) => s.blockedByAllowlist);
+ if (blocked.length > 0) {
+ items.push({
+ severity: "warning",
+ icon: "shield",
+ title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`,
+ description: blocked.map((s) => s.name).join(", "),
+ });
+ }
+
+ const cronJobs = host.cronJobs ?? [];
+ const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error");
+ if (failedCron.length > 0) {
+ items.push({
+ severity: "error",
+ icon: "clock",
+ title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`,
+ description: failedCron.map((j) => j.name).join(", "),
+ });
+ }
+
+ const now = Date.now();
+ const overdue = cronJobs.filter(
+ (j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000,
+ );
+ if (overdue.length > 0) {
+ items.push({
+ severity: "warning",
+ icon: "clock",
+ title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`,
+ description: overdue.map((j) => j.name).join(", "),
+ });
+ }
+
+ host.attentionItems = items;
}
export async function loadChannelsTab(host: SettingsHost) {
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index e7c7735c8bf..5ee23477ba6 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -8,20 +8,22 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
-import type { ThemeMode } from "./theme.ts";
+import type { ResolvedTheme, ThemeMode } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
+ AttentionItem,
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronRunLogEntry,
CronStatus,
- HealthSnapshot,
+ HealthSummary,
LogEntry,
LogLevel,
+ ModelCatalogEntry,
NostrProfile,
PresenceEntry,
SessionsUsageResult,
@@ -43,7 +45,8 @@ export type AppViewState = {
basePath: string;
connected: boolean;
theme: ThemeMode;
- themeResolved: "light" | "dark";
+ themeResolved: ResolvedTheme;
+ themeOrder: ThemeMode[];
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
@@ -143,6 +146,7 @@ export type AppViewState = {
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null;
+ agentsSidebarFilter: string;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
@@ -200,10 +204,13 @@ export type AppViewState = {
skillEdits: Record
;
skillMessages: Record;
skillsBusyKey: string | null;
+ healthLoading: boolean;
+ healthResult: HealthSummary | null;
+ healthError: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
- debugHealth: HealthSnapshot | null;
- debugModels: unknown[];
+ debugHealth: HealthSummary | null;
+ debugModels: ModelCatalogEntry[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
@@ -223,6 +230,12 @@ export type AppViewState = {
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
+ // Overview dashboard state
+ attentionItems: AttentionItem[];
+ paletteOpen: boolean;
+ streamMode: boolean;
+ overviewLogLines: string[];
+ overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set;
connect: () => void;
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index db4b290b10e..1c284079c93 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -60,7 +60,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
-import type { ResolvedTheme, ThemeMode } from "./theme.ts";
+import { VALID_THEMES, type ResolvedTheme, type ThemeMode } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@@ -70,9 +70,10 @@ import type {
CronJob,
CronRunLogEntry,
CronStatus,
- HealthSnapshot,
+ HealthSummary,
LogEntry,
LogLevel,
+ ModelCatalogEntry,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
@@ -118,8 +119,9 @@ export class OpenClawApp extends LitElement {
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
- @state() theme: ThemeMode = this.settings.theme ?? "system";
+ @state() theme: ThemeMode = this.settings.theme ?? "dark";
@state() themeResolved: ResolvedTheme = "dark";
+ @state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = [];
@@ -229,6 +231,7 @@ export class OpenClawApp extends LitElement {
@state() agentSkillsError: string | null = null;
@state() agentSkillsReport: SkillStatusReport | null = null;
@state() agentSkillsAgentId: string | null = null;
+ @state() agentsSidebarFilter = "";
@state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null;
@@ -304,6 +307,23 @@ export class OpenClawApp extends LitElement {
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
+ // Overview dashboard state
+ @state() attentionItems: import("./types.js").AttentionItem[] = [];
+ @state() paletteOpen = false;
+ paletteQuery = "";
+ paletteActiveIndex = 0;
+ @state() streamMode = (() => {
+ try {
+ const stored = localStorage.getItem("openclaw:stream-mode");
+ // Default to true (redacted) unless explicitly disabled
+ return stored === null ? true : stored === "true";
+ } catch {
+ return true;
+ }
+ })();
+ @state() overviewLogLines: string[] = [];
+ @state() overviewLogCursor = 0;
+
@state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@@ -312,10 +332,14 @@ export class OpenClawApp extends LitElement {
@state() skillsBusyKey: string | null = null;
@state() skillMessages: Record = {};
+ @state() healthLoading = false;
+ @state() healthResult: HealthSummary | null = null;
+ @state() healthError: string | null = null;
+
@state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null;
- @state() debugHealth: HealthSnapshot | null = null;
- @state() debugModels: unknown[] = [];
+ @state() debugHealth: HealthSummary | null = null;
+ @state() debugModels: ModelCatalogEntry[] = [];
@state() debugHeartbeat: unknown = null;
@state() debugCallMethod = "";
@state() debugCallParams = "{}";
@@ -354,8 +378,6 @@ export class OpenClawApp extends LitElement {
basePath = "";
private popStateHandler = () =>
onPopStateInternal(this as unknown as Parameters[0]);
- private themeMedia: MediaQueryList | null = null;
- private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
createRenderRoot() {
@@ -433,6 +455,19 @@ export class OpenClawApp extends LitElement {
setTheme(next: ThemeMode, context?: Parameters[2]) {
setThemeInternal(this as unknown as Parameters[0], next, context);
+ this.themeOrder = this.buildThemeOrder(next);
+ }
+
+ buildThemeOrder(active: ThemeMode): ThemeMode[] {
+ const all = [...VALID_THEMES];
+ const rest = all.filter((id) => id !== active);
+ return [active, ...rest];
+ }
+
+ handleThemeToggleCollapse() {
+ setTimeout(() => {
+ this.themeOrder = this.buildThemeOrder(this.theme);
+ }, 80);
}
async loadOverview() {
diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts
new file mode 100644
index 00000000000..fd3916d78c7
--- /dev/null
+++ b/ui/src/ui/chat/deleted-messages.ts
@@ -0,0 +1,49 @@
+const PREFIX = "openclaw:deleted:";
+
+export class DeletedMessages {
+ private key: string;
+ private _keys = new Set();
+
+ constructor(sessionKey: string) {
+ this.key = PREFIX + sessionKey;
+ this.load();
+ }
+
+ has(key: string): boolean {
+ return this._keys.has(key);
+ }
+
+ delete(key: string): void {
+ this._keys.add(key);
+ this.save();
+ }
+
+ restore(key: string): void {
+ this._keys.delete(key);
+ this.save();
+ }
+
+ clear(): void {
+ this._keys.clear();
+ this.save();
+ }
+
+ private load(): void {
+ try {
+ const raw = localStorage.getItem(this.key);
+ if (!raw) {
+ return;
+ }
+ const arr = JSON.parse(raw);
+ if (Array.isArray(arr)) {
+ this._keys = new Set(arr.filter((s) => typeof s === "string"));
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ private save(): void {
+ localStorage.setItem(this.key, JSON.stringify([...this._keys]));
+ }
+}
diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts
index 7c36713c3c0..0eb3f2251f8 100644
--- a/ui/src/ui/chat/grouped-render.ts
+++ b/ui/src/ui/chat/grouped-render.ts
@@ -1,9 +1,10 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity.ts";
+import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { detectTextDirection } from "../text-direction.ts";
-import type { MessageGroup } from "../types/chat-types.ts";
+import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
extractTextCached,
@@ -111,6 +112,7 @@ export function renderMessageGroup(
showReasoning: boolean;
assistantName?: string;
assistantAvatar?: string | null;
+ onDelete?: () => void;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
@@ -148,6 +150,16 @@ export function renderMessageGroup(