diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 3004c0e8549..49b74a5932f 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -315,6 +315,10 @@ describe("isCronSessionKey", () => { describe("resolveSessionOptionGroups", () => { const sessions: SessionsListResult = { + ts: 0, + path: "", + count: 3, + defaults: { model: null, contextTokens: null }, sessions: [ row({ key: "agent:main:main" }), row({ key: "agent:main:cron:daily" }), diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7ec70a651dd..dbfd45b4ef8 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -277,9 +277,10 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { + const updatableState = state as AppViewState & { requestUpdate?: () => void }; const requestHostUpdate = - typeof (state as { requestUpdate?: unknown }).requestUpdate === "function" - ? () => (state as { requestUpdate: () => void }).requestUpdate() + typeof updatableState.requestUpdate === "function" + ? () => updatableState.requestUpdate?.() : undefined; // Gate: require successful gateway connection before showing the dashboard. @@ -757,6 +758,7 @@ export function renderApp(state: AppViewState) { error: state.cronError, busy: state.cronBusy, form: state.cronForm, + editingJobId: state.cronEditingJobId, channels: state.channelsSnapshot?.channelMeta?.length ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) : (state.channelsSnapshot?.channelOrder ?? []), diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 434034251b3..d1dcf7a9ab5 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -34,8 +34,6 @@ const createHost = (tab: Tab): SettingsHost => ({ eventLog: [], eventLogBuffer: [], basePath: "", - themeMedia: null, - themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, systemThemeCleanup: null, diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts index d7482460a13..1be30ed6107 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/ui/controllers/agents.test.ts @@ -204,7 +204,8 @@ describe("loadToolsCatalog", () => { expect(state.toolsCatalogResult).toEqual(replacementPayload); expect(state.toolsCatalogLoading).toBe(false); - resolveMain?.({ + expect(resolveMain).not.toBeNull(); + resolveMain!({ agentId: "main", profiles: [{ id: "full", label: "Full" }], groups: [], diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 9ae61351ce9..ab67368a56b 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UiSettings } from "./storage.ts"; function createStorageMock(): Storage { const store = new Map(); @@ -62,7 +63,7 @@ function expectedGatewayUrl(basePath: string): string { return `${proto}://${location.host}${basePath}`; } -function createSettings(overrides: Record = {}) { +function createSettings(overrides: Partial = {}): UiSettings { return { gatewayUrl: "wss://gateway.example:8443/openclaw", token: "", diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 10d66068288..e3a91540a46 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -410,6 +410,15 @@ export type { SessionUsageTimeSeries, } from "./usage-types.ts"; +export type CronRunStatus = "ok" | "error" | "skipped"; +export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; +export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +export type CronRunScope = "job" | "all"; +export type CronRunsStatusValue = CronRunStatus; +export type CronRunsStatusFilter = "all" | CronRunStatus; +export type CronSortDir = "asc" | "desc"; + export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } @@ -424,9 +433,15 @@ export type CronPayload = kind: "agentTurn"; message: string; model?: string; + fallbacks?: string[]; thinking?: string; timeoutSeconds?: number; + allowUnsafeExternalContent?: boolean; lightContext?: boolean; + deliver?: boolean; + channel?: string; + to?: string; + bestEffortDeliver?: boolean; }; export type CronDelivery = { @@ -458,9 +473,15 @@ export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; lastRunAtMs?: number; - lastStatus?: "ok" | "error" | "skipped"; + lastRunStatus?: CronRunStatus; + lastStatus?: CronRunStatus; lastError?: string; + lastErrorReason?: string; lastDurationMs?: number; + consecutiveErrors?: number; + lastDelivered?: boolean; + lastDeliveryStatus?: CronDeliveryStatus; + lastDeliveryError?: string; lastFailureAlertAtMs?: number; }; @@ -484,12 +505,46 @@ export type CronStatus = { export type CronRunLogEntry = { ts: number; jobId: string; - status: "ok" | "error" | "skipped"; + action?: "finished"; + status?: CronRunStatus; durationMs?: number; error?: string; summary?: string; + delivered?: boolean; + deliveryStatus?: CronDeliveryStatus; + deliveryError?: string; sessionId?: string; sessionKey?: string; + runAtMs?: number; + nextRunAtMs?: number; + model?: string; + provider?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; + jobName?: string; +}; + +export type CronJobsListResult = { + jobs: CronJob[]; + total?: number; + limit?: number; + offset?: number; + nextOffset?: number | null; + hasMore?: boolean; +}; + +export type CronRunsResult = { + entries: CronRunLogEntry[]; + total?: number; + limit?: number; + offset?: number; + nextOffset?: number | null; + hasMore?: boolean; }; export type SkillsStatusConfigCheck = { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d67acd77485..7aec524c111 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -40,12 +40,15 @@ function createProps(overrides: Partial = {}): ChatProps { focusMode: false, assistantName: "OpenClaw", assistantAvatar: null, + agentsList: null, + currentAgentId: "main", onRefresh: () => undefined, onToggleFocusMode: () => undefined, onDraftChange: () => undefined, onSend: () => undefined, onQueueRemove: () => undefined, onNewSession: () => undefined, + onAgentChange: () => undefined, ...overrides, }; } @@ -190,18 +193,17 @@ describe("chat view", () => { createProps({ canAbort: true, onAbort, + stream: "in-flight", }), ), container, ); - const stopButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Stop", - ); - expect(stopButton).not.toBeUndefined(); + const stopButton = container.querySelector('button[title="Stop"]'); + expect(stopButton).not.toBeNull(); stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onAbort).toHaveBeenCalledTimes(1); - expect(container.textContent).not.toContain("New session"); + expect(container.querySelector('button[title="New session"]')).toBeNull(); }); it("shows a new session button when aborting is unavailable", () => { @@ -217,13 +219,13 @@ describe("chat view", () => { container, ); - const newSessionButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "New session", + const newSessionButton = container.querySelector( + 'button[title="New session"]', ); - expect(newSessionButton).not.toBeUndefined(); + expect(newSessionButton).not.toBeNull(); newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onNewSession).toHaveBeenCalledTimes(1); - expect(container.textContent).not.toContain("Stop"); + expect(container.querySelector('button[title="Stop"]')).toBeNull(); }); it("shows sender labels from sanitized gateway messages instead of generic You", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index fb1b7cb4a80..5ea31847f2e 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1234,6 +1234,20 @@ export function renderChat(props: ChatProps) {
${nothing /* search hidden for now */} + ${ + canAbort + ? nothing + : html` + + ` + } diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 296a692d115..836b72dbbcc 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -360,7 +360,9 @@ export function renderCron(props: CronProps) { props.runsScope === "all" ? t("cron.jobList.allJobs") : (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob")); - const runs = props.runs; + const runs = props.runs.toSorted((a, b) => + props.runsSortDir === "asc" ? a.ts - b.ts : b.ts - a.ts, + ); const runStatusOptions = getRunStatusOptions(); const runDeliveryOptions = getRunDeliveryOptions(); const selectedStatusLabels = runStatusOptions @@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) { ?disabled=${props.busy} @click=${(event: Event) => { event.stopPropagation(); - selectAnd(() => props.onLoadRuns(job.id)); + props.onLoadRuns(job.id); }} > ${t("cron.jobList.history")} diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 453c216592a..50b35cae883 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -23,7 +23,18 @@ function buildProps(result: SessionsListResult): SessionsProps { includeGlobal: false, includeUnknown: false, basePath: "", + searchQuery: "", + sortColumn: "updated", + sortDir: "desc", + page: 0, + pageSize: 25, + actionsOpenKey: null, onFiltersChange: () => undefined, + onSearchChange: () => undefined, + onSortChange: () => undefined, + onPageChange: () => undefined, + onPageSizeChange: () => undefined, + onActionsOpenChange: () => undefined, onRefresh: () => undefined, onPatch: () => undefined, onDelete: () => undefined,