diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fced23a7992..a72c845262a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -277,6 +277,7 @@ export const en: TranslationMap = { tabs: { scene: "Scene", diary: "Diary", + advanced: "Advanced", }, header: { refresh: "Refresh", @@ -302,6 +303,21 @@ export const en: TranslationMap = { rem: "Rem", off: "off", }, + advanced: { + eyebrow: "Operator Review", + title: "Grounded Replay + Promotion", + description: + "Inspect grounded replay output and use maintenance actions without cluttering the main Dreaming scene.", + stagedTitle: "Grounded Replay", + shortTermTitle: "Short-term Queue", + signalsTitle: "Signal Hotspots", + promotedTitle: "Recent Promotions", + emptyGrounded: "No staged grounded replay entries right now.", + emptyShortTerm: "No short-term entries to inspect.", + emptySignals: "No signal-rich entries to inspect.", + emptyPromoted: "No recent promotions to inspect.", + updatedPrefix: "updated", + }, stats: { shortTerm: "Short-term", grounded: "Grounded", diff --git a/ui/src/styles/dreams.css b/ui/src/styles/dreams.css index dfb91f529fb..21d21efa708 100644 --- a/ui/src/styles/dreams.css +++ b/ui/src/styles/dreams.css @@ -415,13 +415,6 @@ color: var(--ok-muted); } -.dreams__actions { - display: flex; - align-items: center; - gap: 10px; - margin-top: 14px; -} - .dreams__status-dot { width: 6px; height: 6px; @@ -457,6 +450,181 @@ color: var(--muted); } +/* =========================================== + Dreaming Advanced – Operator Review + =========================================== */ + +.dreams-advanced { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px 32px 40px; + flex: 1; + min-height: 320px; + min-width: 0; + overflow: auto; +} + +.dreams-advanced__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.dreams-advanced__intro { + max-width: 720px; +} + +.dreams-advanced__eyebrow { + display: block; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-muted); + margin-bottom: 10px; +} + +.dreams-advanced__title { + margin: 0; + font-size: 24px; + line-height: 1.1; + color: var(--text); +} + +.dreams-advanced__description { + margin: 10px 0 0; + max-width: 60ch; + font-size: 13px; + line-height: 1.6; + color: var(--muted); +} + +.dreams-advanced__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.dreams-advanced__summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.dreams-advanced__summary-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); + background: color-mix(in oklab, var(--panel) 88%, transparent); +} + +.dreams-advanced__summary-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.dreams-advanced__summary-value { + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.dreams-advanced__sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.dreams-advanced__section { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 16px; + border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); + background: color-mix(in oklab, var(--panel) 86%, transparent); + overflow: hidden; +} + +.dreams-advanced__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid color-mix(in oklab, var(--border) 62%, transparent); +} + +.dreams-advanced__section-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); +} + +.dreams-advanced__section-count { + min-width: 26px; + height: 26px; + padding: 0 8px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in oklab, var(--accent-subtle) 85%, transparent); + color: var(--accent); + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dreams-advanced__list { + display: flex; + flex-direction: column; +} + +.dreams-advanced__item { + padding: 14px 16px; + border-top: 1px solid color-mix(in oklab, var(--border) 50%, transparent); +} + +.dreams-advanced__item:first-child { + border-top: none; +} + +.dreams-advanced__snippet { + font-size: 13px; + line-height: 1.45; + color: var(--text); +} + +.dreams-advanced__source, +.dreams-advanced__meta, +.dreams-advanced__empty { + font-size: 11px; + line-height: 1.45; + color: var(--muted); + margin-top: 8px; +} + +.dreams-advanced__source { + font-family: var(--mono); +} + +.dreams-advanced__empty { + padding: 16px; +} + /* =========================================== Dream Diary – Scroll section below hero =========================================== */ @@ -667,6 +835,15 @@ min-height: calc(100vh - 96px); } + .dreams-advanced { + padding: 20px 16px 32px; + } + + .dreams-advanced__summary, + .dreams-advanced__sections { + grid-template-columns: 1fr; + } + .dreams__phases { gap: 12px; flex-wrap: wrap; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1b6cea2cccf..c259a5e7867 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -69,6 +69,7 @@ import { backfillDreamDiary, loadDreamDiary, loadDreamingStatus, + resetGroundedShortTerm, resetDreamDiary, resolveConfiguredDreaming, updateDreamingEnabled, @@ -1930,8 +1931,14 @@ export function renderApp(state: AppViewState) { ${state.tab === "dreams" ? renderDreaming({ active: dreamingOn, + shortTermCount: state.dreamingStatus?.shortTermCount ?? 0, + groundedSignalCount: state.dreamingStatus?.groundedSignalCount ?? 0, + totalSignalCount: state.dreamingStatus?.totalSignalCount ?? 0, promotedCount: state.dreamingStatus?.promotedToday ?? 0, phases: state.dreamingStatus?.phases ?? undefined, + shortTermEntries: state.dreamingStatus?.shortTermEntries ?? [], + signalEntries: state.dreamingStatus?.signalEntries ?? [], + promotedEntries: state.dreamingStatus?.promotedEntries ?? [], dreamingOf: null, nextCycle: dreamingNextCycle, timezone: state.dreamingStatus?.timezone ?? null, @@ -1947,6 +1954,7 @@ export function renderApp(state: AppViewState) { onRefreshDiary: () => loadDreamDiary(state), onBackfillDiary: () => backfillDreamDiary(state), onResetDiary: () => resetDreamDiary(state), + onResetGroundedShortTerm: () => resetGroundedShortTerm(state), onRequestUpdate: requestHostUpdate, }) : nothing} diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 20dc99dbe4f..562de28c104 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -7,12 +7,64 @@ import { renderDreaming, setDreamSubTab, type DreamingProps } from "./dreaming.t function buildProps(overrides?: Partial): DreamingProps { return { active: true, + shortTermCount: 47, + groundedSignalCount: 9, + totalSignalCount: 182, promotedCount: 12, phases: { light: { enabled: true, cron: "0 * * * *", nextRunAtMs: Date.parse("2026-04-05T11:30:00Z") }, deep: { enabled: true, cron: "30 * * * *", nextRunAtMs: Date.parse("2026-04-05T12:00:00Z") }, rem: { enabled: false, cron: "0 4 * * *" }, }, + shortTermEntries: [ + { + key: "memory:memory/2026-04-05.md:1:2", + path: "memory/2026-04-05.md", + startLine: 1, + endLine: 2, + snippet: "Emma prefers shorter, lower-pressure check-ins.", + recallCount: 2, + dailyCount: 1, + groundedCount: 1, + totalSignalCount: 3, + lightHits: 1, + remHits: 1, + phaseHitCount: 2, + }, + ], + signalEntries: [ + { + key: "memory:memory/2026-04-05.md:1:2", + path: "memory/2026-04-05.md", + startLine: 1, + endLine: 2, + snippet: "Emma prefers shorter, lower-pressure check-ins.", + recallCount: 2, + dailyCount: 1, + groundedCount: 1, + totalSignalCount: 3, + lightHits: 1, + remHits: 1, + phaseHitCount: 2, + }, + ], + promotedEntries: [ + { + key: "memory:memory/2026-04-04.md:4:5", + path: "memory/2026-04-04.md", + startLine: 4, + endLine: 5, + snippet: "Use the Happy Together calendar for flights.", + recallCount: 3, + dailyCount: 2, + groundedCount: 4, + totalSignalCount: 9, + lightHits: 0, + remHits: 0, + phaseHitCount: 0, + promotedAt: "2026-04-05T04:00:00.000Z", + }, + ], dreamingOf: null, nextCycle: "4:00 AM", timezone: "America/Los_Angeles", @@ -29,6 +81,7 @@ function buildProps(overrides?: Partial): DreamingProps { onRefreshDiary: () => {}, onBackfillDiary: () => {}, onResetDiary: () => {}, + onResetGroundedShortTerm: () => {}, ...overrides, }; } @@ -73,13 +126,14 @@ describe("dreaming view", () => { expect(container.querySelector(".dreams__phase--off")?.textContent).toContain("off"); }); - it("renders scene backfill and reset controls", () => { + it("keeps maintenance controls out of the scene tab", () => { const container = renderInto(buildProps()); const buttons = [...container.querySelectorAll("button")].map((node) => node.textContent?.trim(), ); - expect(buttons).toContain("Backfill"); - expect(buttons).toContain("Reset"); + expect(buttons).not.toContain("Backfill"); + expect(buttons).not.toContain("Reset"); + expect(buttons).not.toContain("Clear Grounded"); }); it("shows dream bubble when active", () => { @@ -131,9 +185,10 @@ describe("dreaming view", () => { it("renders sub-tab navigation", () => { const container = renderInto(buildProps()); const tabs = container.querySelectorAll(".dreams__tab"); - expect(tabs.length).toBe(2); + expect(tabs.length).toBe(3); expect(tabs[0]?.textContent).toContain("Scene"); expect(tabs[1]?.textContent).toContain("Diary"); + expect(tabs[2]?.textContent).toContain("Advanced"); }); it("renders dream diary with parsed entry on diary tab", () => { @@ -258,5 +313,24 @@ describe("dreaming view", () => { setDreamSubTab("scene"); }); + it("renders operator actions and evidence lists on the advanced tab", () => { + setDreamSubTab("advanced"); + const container = renderInto(buildProps()); + expect(container.querySelector(".dreams-advanced__title")?.textContent).toContain( + "Grounded Replay", + ); + const buttons = [...container.querySelectorAll("button")].map((node) => + node.textContent?.trim(), + ); + expect(buttons).toContain("Backfill"); + expect(buttons).toContain("Reset"); + expect(buttons).toContain("Clear Grounded"); + expect(container.querySelector(".dreams-advanced__summary-value")?.textContent).toBe("47"); + expect(container.querySelector(".dreams-advanced__item")?.textContent).toContain( + "Emma prefers shorter", + ); + setDreamSubTab("scene"); + }); + // Toggle lives in the page header (app-render.ts), not inside the dreaming view. }); diff --git a/ui/src/ui/views/dreaming.ts b/ui/src/ui/views/dreaming.ts index 8b0e6c60612..fd7a3e38bbe 100644 --- a/ui/src/ui/views/dreaming.ts +++ b/ui/src/ui/views/dreaming.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; +import type { DreamingEntry } from "../controllers/dreaming.ts"; // ── Diary entry parser ───────────────────────────────────────────────── @@ -89,12 +90,18 @@ type DreamingPhaseInfo = { export type DreamingProps = { active: boolean; + shortTermCount: number; + groundedSignalCount: number; + totalSignalCount: number; promotedCount: number; phases?: { light: DreamingPhaseInfo; deep: DreamingPhaseInfo; rem: DreamingPhaseInfo; }; + shortTermEntries: DreamingEntry[]; + signalEntries: DreamingEntry[]; + promotedEntries: DreamingEntry[]; dreamingOf: string | null; nextCycle: string | null; timezone: string | null; @@ -110,6 +117,7 @@ export type DreamingProps = { onRefreshDiary: () => void; onBackfillDiary: () => void; onResetDiary: () => void; + onResetGroundedShortTerm: () => void; onRequestUpdate?: () => void; }; @@ -145,7 +153,7 @@ const DREAM_SWAP_MS = 6_000; // ── Sub-tab state ───────────────────────────────────────────────────── -type DreamSubTab = "scene" | "diary"; +type DreamSubTab = "scene" | "diary" | "advanced"; let _subTab: DreamSubTab = "scene"; export function setDreamSubTab(tab: DreamSubTab): void { @@ -254,9 +262,22 @@ export function renderDreaming(props: DreamingProps) { > ${t("dreaming.tabs.diary")} + - ${_subTab === "scene" ? renderScene(props, idle, dreamText) : renderDiarySection(props)} + ${_subTab === "scene" + ? renderScene(props, idle, dreamText) + : _subTab === "diary" + ? renderDiarySection(props) + : renderAdvancedSection(props)} `; } @@ -380,24 +401,175 @@ function renderScene(props: DreamingProps, idle: boolean, dreamText: string) { )} - -
- - + ${props.statusError + ? html`
${props.statusError}
` + : nothing} + + `; +} + +function formatRange(path: string, startLine: number, endLine: number): string { + return startLine === endLine ? `${path}:${startLine}` : `${path}:${startLine}-${endLine}`; +} + +function formatCompactDateTime(value: string): string { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + return value; + } + return new Date(parsed).toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function renderAdvancedEntryList( + titleKey: string, + emptyKey: string, + entries: DreamingEntry[], + meta: (entry: DreamingEntry) => string[], +) { + return html` +
+
+ ${t(titleKey)} + ${entries.length} +
+ ${entries.length === 0 + ? html`
${t(emptyKey)}
` + : html` +
+ ${entries.map( + (entry) => html` +
+
${entry.snippet}
+
+ ${formatRange(entry.path, entry.startLine, entry.endLine)} +
+
+ ${meta(entry) + .filter((part) => part.length > 0) + .join(" · ")} +
+
+ `, + )} +
+ `} +
+ `; +} + +function renderAdvancedSection(props: DreamingProps) { + const groundedEntries = props.shortTermEntries.filter((entry) => entry.groundedCount > 0); + + return html` +
+
+
+ ${t("dreaming.advanced.eyebrow")} +

${t("dreaming.advanced.title")}

+

${t("dreaming.advanced.description")}

+
+
+ + + +
+
+ +
+
+ ${t("dreaming.stats.shortTerm")} + ${props.shortTermCount} +
+
+ ${t("dreaming.stats.grounded")} + ${props.groundedSignalCount} +
+
+ ${t("dreaming.stats.signals")} + ${props.totalSignalCount} +
+
+ ${t("dreaming.stats.promoted")} + ${props.promotedCount} +
+
+ +
+ ${renderAdvancedEntryList( + "dreaming.advanced.stagedTitle", + "dreaming.advanced.emptyGrounded", + groundedEntries, + (entry) => [ + entry.groundedCount > 0 + ? `${entry.groundedCount} ${t("dreaming.stats.grounded").toLowerCase()}` + : "", + entry.recallCount > 0 ? `${entry.recallCount} recall` : "", + entry.dailyCount > 0 ? `${entry.dailyCount} daily` : "", + ], + )} + ${renderAdvancedEntryList( + "dreaming.advanced.shortTermTitle", + "dreaming.advanced.emptyShortTerm", + props.shortTermEntries, + (entry) => [ + entry.recallCount > 0 ? `${entry.recallCount} recall` : "", + entry.dailyCount > 0 ? `${entry.dailyCount} daily` : "", + entry.groundedCount > 0 + ? `${entry.groundedCount} ${t("dreaming.stats.grounded").toLowerCase()}` + : "", + entry.phaseHitCount > 0 ? `${entry.phaseHitCount} phase hit` : "", + ], + )} + ${renderAdvancedEntryList( + "dreaming.advanced.signalsTitle", + "dreaming.advanced.emptySignals", + props.signalEntries, + (entry) => [ + `${entry.totalSignalCount} ${t("dreaming.stats.signals").toLowerCase()}`, + entry.phaseHitCount > 0 ? `${entry.phaseHitCount} phase hit` : "", + ], + )} + ${renderAdvancedEntryList( + "dreaming.advanced.promotedTitle", + "dreaming.advanced.emptyPromoted", + props.promotedEntries, + (entry) => [ + entry.promotedAt + ? `${t("dreaming.advanced.updatedPrefix")} ${formatCompactDateTime(entry.promotedAt)}` + : "", + entry.groundedCount > 0 + ? `${entry.groundedCount} ${t("dreaming.stats.grounded").toLowerCase()}` + : "", + entry.totalSignalCount > 0 + ? `${entry.totalSignalCount} ${t("dreaming.stats.signals").toLowerCase()}` + : "", + ], + )}
${props.statusError