diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f586399f36..62e67d9fec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash. - Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden. - Compaction: skip oversized pre-compaction checkpoint snapshots and prune duplicate long user turns from compaction input and rotated successor transcripts, preventing retry storms from being preserved across checkpoint cycles. Fixes #72780. Thanks @SweetSophia. +- Control UI/Cron: render cron job prompts and run summaries as sanitized markdown in the dashboard, with full-width block content, safer link clicks, and no duplicate error text when a failed run has no summary. Supersedes #48504. Thanks @garethdaine. - Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy. - Agents/reasoning: treat orphan closing reasoning tags with following answer text as a privacy boundary across delivery, history, streaming, and Control UI sanitizers so malformed local-model output cannot leak chain-of-thought text. Fixes #67092. Thanks @AnildoSilva. - Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 87fc03eab33..fdb44d1e39a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1295,21 +1295,86 @@ accent-color: var(--accent); } -.cron-run-entry { - align-items: start; +.list-item.cron-run-entry { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.cron-run-entry__header { + display: flex; + flex-direction: column; + align-items: baseline; + gap: 8px; + padding: 12px; + width: 100%; + box-sizing: border-box; + background: var(--secondary); + border-radius: var(--radius-md) var(--radius-md) 0 0; + border-bottom: 1px solid var(--border); +} + +@media (min-width: 1101px) { + .cron-run-entry__header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 16px; + align-items: start; + } +} + +:root[data-theme-mode="light"] .cron-run-entry__header { + background: var(--bg-muted); } .cron-run-entry__meta { text-align: right; - min-width: 220px; + min-width: 0; } -.cron-run-entry__summary { - white-space: pre-wrap; +.cron-run-entry__body { + padding: 12px; line-height: 1.45; + min-width: 0; + width: 100%; + box-sizing: border-box; } @media (max-width: 1100px) { + .cron-job.list-item { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + max-width: 100%; + } + + .cron-job .cron-job-header { + display: flex; + flex-direction: column; + gap: 8px; + border-radius: var(--radius-md) var(--radius-md) 0 0; + width: 100%; + } + + .cron-job .list-meta { + min-width: 0; + } + + .cron-job .cron-job-header .cron-job-state { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 16px; + } + + .cron-job .cron-job-detail, + .cron-job .cron-job-footer { + width: 100%; + box-sizing: border-box; + } + .cron-summary-strip { flex-direction: column; } @@ -1373,6 +1438,27 @@ min-width: 0; text-align: left; } + + .list-item.cron-run-entry { + padding: 0; + } + + .cron-run-entry__header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + } + + .cron-run-entry__body { + padding: 10px; + } + + .cron-run-entry__body pre, + .cron-run-entry__body table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } } :root[data-theme-mode="light"] .field input, @@ -1852,17 +1938,40 @@ .cron-job { grid-template-columns: minmax(0, 1fr) minmax(240px, 300px); grid-template-areas: - "main meta" + "header header" + "payload payload" "footer footer"; - row-gap: 10px; + row-gap: 0; + padding: 0; + overflow: hidden; } -.cron-job .list-main { - grid-area: main; +.cron-job .cron-job-header { + grid-area: header; + display: flex; + flex-direction: column; + align-items: baseline; + gap: 8px; + padding: 12px; + background: var(--secondary); + border-radius: var(--radius-md) var(--radius-md) 0 0; + border-bottom: 1px solid var(--border); +} + +@media (min-width: 1101px) { + .cron-job .cron-job-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 300px); + gap: 16px; + align-items: start; + } +} + +:root[data-theme-mode="light"] .cron-job .cron-job-header { + background: var(--bg-muted); } .cron-job .list-meta { - grid-area: meta; min-width: 240px; gap: 8px; } @@ -1873,8 +1982,8 @@ justify-content: space-between; align-items: center; gap: 12px; - border-top: 1px solid var(--border); - padding-top: 10px; + border-top: none; + padding: 4px 12px 12px; } .cron-job-chips { @@ -1882,9 +1991,18 @@ } .cron-job-detail { + grid-area: payload; + display: grid; + gap: 8px; + margin-top: 2px; + padding: 8px 12px 12px; + overflow: hidden; +} + +.cron-job-detail-section { display: grid; gap: 3px; - margin-top: 2px; + min-width: 0; } .cron-job-detail-label { @@ -1893,11 +2011,17 @@ font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; + padding: 4px 8px; + background: var(--secondary); + border-radius: var(--radius-sm); + display: inline-block; + width: fit-content; } .cron-job-detail-value { font-size: 13px; line-height: 1.35; + min-width: 0; overflow-wrap: anywhere; } @@ -1988,8 +2112,8 @@ .cron-job { grid-template-columns: 1fr; grid-template-areas: - "main" - "meta" + "header" + "payload" "footer"; } @@ -2891,6 +3015,101 @@ td.data-table-key-col { background: var(--secondary); } +/* Cron markdown overrides must come after .chat-text rules to win cascade. */ +.cron-run-entry__body.chat-text table, +.cron-job-detail-value.chat-text table { + display: table; + width: 100%; +} + +.cron-run-entry__body.chat-text pre, +.cron-run-entry__body.chat-text blockquote, +.cron-job-detail-value.chat-text pre, +.cron-job-detail-value.chat-text blockquote { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.cron-run-entry__body.chat-text pre + p, +.cron-run-entry__body.chat-text blockquote + p, +.cron-run-entry__body.chat-text table + p, +.cron-run-entry__body.chat-text p + pre, +.cron-run-entry__body.chat-text p + blockquote, +.cron-run-entry__body.chat-text p + table, +.cron-job-detail-value.chat-text pre + p, +.cron-job-detail-value.chat-text blockquote + p, +.cron-job-detail-value.chat-text table + p, +.cron-job-detail-value.chat-text p + pre, +.cron-job-detail-value.chat-text p + blockquote, +.cron-job-detail-value.chat-text p + table { + margin-top: 0.75em; +} + +.cron-run-entry__body.chat-text pre, +.cron-run-entry__body.chat-text blockquote, +.cron-run-entry__body.chat-text table, +.cron-job-detail-value.chat-text pre, +.cron-job-detail-value.chat-text blockquote, +.cron-job-detail-value.chat-text table { + margin-bottom: 0.75em; +} + +.cron-run-entry__body.chat-text > :last-child, +.cron-job-detail-value.chat-text > :last-child { + margin-bottom: 0; +} + +@media (max-width: 600px) { + .cron-job .cron-job-header { + padding: 10px; + gap: 6px; + } + + .cron-job .cron-job-header .cron-job-state { + gap: 4px 12px; + } + + .cron-job .cron-job-detail { + padding: 10px; + } + + .cron-job .cron-job-detail-value { + font-size: 13px; + overflow-x: auto; + } + + .cron-job .cron-job-detail-value table, + .cron-run-entry__body table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .cron-job .cron-job-detail-value pre { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .cron-job .cron-job-footer { + padding: 8px 10px 10px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + gap: 8px; + } + + .cron-job .cron-job-footer .chip-row, + .cron-job .cron-job-footer .cron-job-actions { + flex-wrap: nowrap; + flex-shrink: 0; + } + + .cron-job .cron-job-footer .cron-job-actions { + gap: 6px; + } +} + /* Tool cards */ .chat-tool-card { margin-top: 8px; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 12a32a124ff..1bb6595aecd 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -232,7 +232,7 @@ describe("cron view", () => { expect(runHistoryCard).not.toBeUndefined(); const summaries = Array.from( - runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], + runHistoryCard?.querySelectorAll(".cron-run-entry__body") ?? [], ).map((el) => (el.textContent ?? "").trim()); expect(summaries[0]).toBe("newer run"); expect(summaries[1]).toBe("older run"); @@ -299,6 +299,104 @@ describe("cron view", () => { expect(container.textContent).toContain("https://example.invalid/cron"); }); + it("renders cron job prompts and run summaries as sanitized markdown", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = { + ...createJob("job-md"), + sessionTarget: "isolated" as const, + payload: { + kind: "agentTurn" as const, + message: "## Plan\n\n- **Ship** [docs](https://example.com)\n\n", + }, + delivery: { mode: "announce" as const, channel: "telegram", to: "123" }, + }; + + render( + renderCron( + createProps({ + jobs: [job], + runs: [ + { + ts: 2, + jobId: "job-md", + status: "ok", + summary: "Done with **markdown**\n\n| A | B |\n| - | - |\n| 1 | 2 |", + }, + ], + onLoadRuns, + }), + ), + container, + ); + + const prompt = container.querySelector(".cron-job-detail-value.chat-text"); + expect(prompt?.querySelector("strong")?.textContent).toBe("Ship"); + expect(prompt?.querySelector("a")?.getAttribute("href")).toBe("https://example.com"); + expect(prompt?.querySelector("script")).toBeNull(); + + const promptLink = prompt?.querySelector("a"); + promptLink?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onLoadRuns).not.toHaveBeenCalled(); + + const row = container.querySelector(".cron-job"); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onLoadRuns).toHaveBeenCalledWith("job-md"); + + const runBody = container.querySelector(".cron-run-entry__body.chat-text"); + expect(runBody?.querySelector("strong")?.textContent).toBe("markdown"); + expect(runBody?.querySelector("table")).not.toBeNull(); + }); + + it("shows run errors in one place when no summary exists", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + runs: [ + { + ts: 2, + jobId: "job-error", + status: "error", + error: "Failed with **markdown**", + }, + ], + }), + ), + container, + ); + + expect(container.querySelector(".cron-run-entry__meta")?.textContent).not.toContain( + "Failed with", + ); + expect(container.querySelector(".cron-run-entry__body strong")?.textContent).toBe("markdown"); + }); + + it("treats empty run summaries as absent when an error exists", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + runs: [ + { + ts: 2, + jobId: "job-empty-summary", + status: "error", + summary: "", + error: "Failed with **markdown**", + }, + ], + }), + ), + container, + ); + + expect(container.querySelector(".cron-run-entry__meta")?.textContent).not.toContain( + "Failed with", + ); + expect(container.querySelector(".cron-run-entry__body strong")?.textContent).toBe("markdown"); + }); + it("wires the Edit action and shows save/cancel controls when editing", () => { const container = document.createElement("div"); const onEdit = vi.fn(); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 5af1785859a..38c0c3684db 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../../i18n/index.ts"; import type { CronFieldErrors, @@ -8,6 +9,7 @@ import type { CronJobsScheduleKindFilter, } from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; +import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; @@ -1479,17 +1481,19 @@ function renderJob(job: CronJob, props: CronProps) { }; return html`