fix(ui): address 4 review comments on dashboard-v2

- Reset chat module state on tab navigation (stops STT leak)
- Extract exportChatMarkdown to shared helper (deduplicate)
- Move theme listener cleanup to host instance (fix stale ref)
- Load cron runs on initial Cron tab open
This commit is contained in:
Val Alexander
2026-03-09 17:32:00 -05:00
parent 43430d4900
commit df82c4998d
5 changed files with 49 additions and 55 deletions

View File

@@ -13,7 +13,7 @@ import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts";
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
import { loadDebug } from "./controllers/debug.ts";
import { loadDevices } from "./controllers/devices.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
@@ -34,14 +34,8 @@ import {
import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import { cleanupChatModuleState } from "./views/chat.ts";
/**
* Per-host theme listener cleanup functions.
* Prevents stale closures after component remount by keying cleanup by host instance.
*/
const systemThemeCleanupMap = new WeakMap<SettingsHost, () => void>();
import type { AgentsListResult, AttentionItem } from "./types.ts";
import { resetChatViewState } from "./views/chat.ts";
type SettingsHost = {
settings: UiSettings;
@@ -62,6 +56,7 @@ type SettingsHost = {
agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
pendingGatewayUrl?: string | null;
systemThemeCleanup?: (() => void) | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -287,11 +282,8 @@ export function attachThemeListener(host: SettingsHost) {
}
export function detachThemeListener(host: SettingsHost) {
const cleanup = systemThemeCleanupMap.get(host);
if (cleanup) {
cleanup();
systemThemeCleanupMap.delete(host);
}
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@@ -307,16 +299,13 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
function syncSystemThemeListener(host: SettingsHost) {
// Clean up existing listener if mode is not "system"
if (host.themeMode !== "system") {
const cleanup = systemThemeCleanupMap.get(host);
if (cleanup) {
cleanup();
systemThemeCleanupMap.delete(host);
}
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
return;
}
// Skip if listener already attached for this host
if (systemThemeCleanupMap.has(host)) {
if (host.systemThemeCleanup) {
return;
}
@@ -332,7 +321,7 @@ function syncSystemThemeListener(host: SettingsHost) {
applyResolvedTheme(host, resolveTheme(host.theme, "system"));
};
mql.addEventListener("change", onChange);
systemThemeCleanupMap.set(host, () => mql.removeEventListener("change", onChange));
host.systemThemeCleanup = () => mql.removeEventListener("change", onChange);
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@@ -383,7 +372,7 @@ function applyTabSelection(
// Cleanup chat module state when navigating away from chat
if (prev === "chat" && next !== "chat") {
cleanupChatModuleState();
resetChatViewState();
}
if (next === "chat") {
@@ -596,9 +585,12 @@ export async function loadChannelsTab(host: SettingsHost) {
}
export async function loadCron(host: SettingsHost) {
const app = host as unknown as OpenClawApp;
const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null;
await Promise.all([
loadChannels(host as unknown as OpenClawApp, false),
loadCronStatus(host as unknown as OpenClawApp),
loadCronJobs(host as unknown as OpenClawApp),
loadChannels(app, false),
loadCronStatus(app),
loadCronJobs(app),
loadCronRuns(app, activeCronJobId),
]);
}

View File

@@ -53,7 +53,7 @@ import {
} from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { exportChatMarkdown } from "./chat-export.ts";
import { exportChatMarkdown } from "./chat/export.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";

View File

@@ -1,25 +1 @@
/**
* Export chat history as markdown file.
* Shared utility to prevent code duplication between chat.ts and app.ts.
*/
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${assistantName}-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
export { exportChatMarkdown } from "./chat/export.ts";

24
ui/src/ui/chat/export.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Export chat history as markdown file.
*/
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `chat-${assistantName}-${Date.now()}.md`;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -1,8 +1,8 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { exportChatMarkdown } from "../chat-export.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -149,11 +149,11 @@ let searchQuery = "";
let pinnedExpanded = false;
/**
* Cleanup module-level state when navigating away from chat view.
* Prevents STT recording from continuing after tab switch (which would
* send transcripts to the wrong session) and resets ephemeral UI state.
* Reset module-level chat view state when navigating away from chat.
* Prevents STT recording from continuing after a tab switch and clears
* ephemeral search/slash UI that should not survive navigation.
*/
export function cleanupChatModuleState() {
export function resetChatViewState() {
if (sttRecording) {
stopStt();
sttRecording = false;
@@ -170,6 +170,8 @@ export function cleanupChatModuleState() {
pinnedExpanded = false;
}
export const cleanupChatModuleState = resetChatViewState;
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;