mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 08:52:45 +00:00
Dreaming: simplify sweep flow and add diary surface
This commit is contained in:
@@ -2,6 +2,48 @@
|
||||
Dreams Tab – Sleeping Lobster Animation
|
||||
=========================================== */
|
||||
|
||||
.dreams-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---- Sub-tab bar ---- */
|
||||
|
||||
.dreams__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dreams__tab {
|
||||
padding: 4px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.dreams__tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dreams__tab--active {
|
||||
color: var(--text);
|
||||
background: color-mix(in oklab, var(--panel) 80%, transparent);
|
||||
}
|
||||
|
||||
.dreams {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -237,11 +279,10 @@
|
||||
/* ---- Stats bar ---- */
|
||||
|
||||
.dreams__stats {
|
||||
position: absolute;
|
||||
bottom: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
margin-top: 36px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -271,162 +312,63 @@
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dreams__controls {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
width: min(820px, calc(100% - 48px));
|
||||
padding: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 18%, var(--border));
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--panel) 92%, transparent),
|
||||
color-mix(in oklab, var(--panel) 82%, transparent)
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 18px 48px rgba(3, 7, 18, 0.24);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
/* ---- Dreaming on/off toggle (header bar) ---- */
|
||||
|
||||
.dreams__controls-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dreams__controls-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dreams__controls-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dreams__controls-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dreams__phase-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dreams__phase {
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, var(--panel) 90%, transparent);
|
||||
color: var(--text);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.dreams__phase:hover {
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dreams__phase--active {
|
||||
border-color: color-mix(in oklab, var(--accent) 62%, var(--border));
|
||||
background: color-mix(in oklab, var(--accent-subtle) 70%, var(--panel));
|
||||
}
|
||||
|
||||
.dreams__phase-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dreams__phase-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dreams__phase-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dreams__phase-meta {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dreams__phase-meta span {
|
||||
.dreams__phase-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 7px;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in oklab, var(--bg) 76%, transparent);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, var(--panel) 70%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 200ms ease,
|
||||
color 200ms ease;
|
||||
}
|
||||
|
||||
.dreams__phase-toggle:hover {
|
||||
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
|
||||
}
|
||||
|
||||
.dreams__phase-toggle-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
opacity: 0.4;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
opacity 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.dreams__phase-toggle--on .dreams__phase-toggle-dot {
|
||||
background: var(--ok);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.dreams__phase-toggle--on {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in oklab, var(--ok) 20%, var(--border));
|
||||
}
|
||||
|
||||
.dreams__phase-toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dreams__controls-note,
|
||||
.dreams__controls-error {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dreams__controls-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.dreams {
|
||||
min-height: calc(100vh - 96px);
|
||||
}
|
||||
|
||||
.dreams__stats {
|
||||
position: static;
|
||||
margin-top: 36px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.dreams__controls {
|
||||
position: static;
|
||||
margin: 12px 16px 18px;
|
||||
width: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--panel) 84%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dreams__phase-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Status line ---- */
|
||||
|
||||
.dreams__status {
|
||||
@@ -487,3 +429,281 @@
|
||||
.dreams--idle .dreams__status-detail {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Dream Diary – Scroll section below hero
|
||||
=========================================== */
|
||||
|
||||
.dreams-diary {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px 64px;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--bg) 0%,
|
||||
color-mix(in oklab, var(--bg) 94%, #0d0818) 40%,
|
||||
color-mix(in oklab, var(--bg) 88%, #0d0818) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Ambient shimmer across the diary surface */
|
||||
.dreams-diary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 30%,
|
||||
rgba(251, 191, 36, 0.012) 45%,
|
||||
rgba(255, 77, 77, 0.015) 55%,
|
||||
transparent 70%
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: diary-shimmer 20s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes diary-shimmer {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Diary header ---- */
|
||||
|
||||
.dreams-diary__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dreams-diary__title {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dreams-diary__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dreams-diary__page {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dreams-diary__nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 60%, transparent);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dreams-diary__nav-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
|
||||
}
|
||||
|
||||
.dreams-diary__nav-btn:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- Diary entry ---- */
|
||||
|
||||
.dreams-diary__entry {
|
||||
position: relative;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
padding: 0 0 0 20px;
|
||||
z-index: 1;
|
||||
animation: diary-entry-reveal 1.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes diary-entry-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
filter: blur(8px);
|
||||
}
|
||||
50% {
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Left accent glow line */
|
||||
.dreams-diary__accent {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
height: calc(100% - 8px);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--accent) 40%, transparent) 0%,
|
||||
color-mix(in oklab, var(--accent) 8%, transparent) 100%
|
||||
);
|
||||
animation: diary-glow-pulse 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes diary-glow-pulse {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
.dreams-diary__date {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--accent-muted);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 400;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dreams-diary__prose {
|
||||
/* no styling container needed, just prose spacing */
|
||||
}
|
||||
|
||||
.dreams-diary__para {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: color-mix(in oklab, var(--text) 85%, var(--muted));
|
||||
font-style: italic;
|
||||
animation: diary-text-stream 2.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.dreams-diary__para:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes diary-text-stream {
|
||||
0% {
|
||||
opacity: 0;
|
||||
mask-image: linear-gradient(90deg, black 0%, transparent 0%);
|
||||
-webkit-mask-image: linear-gradient(90deg, black 0%, transparent 0%);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
mask-image: linear-gradient(90deg, black 100%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(90deg, black 100%, transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Empty / error states ---- */
|
||||
|
||||
.dreams-diary__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dreams-diary__empty-moon {
|
||||
color: var(--muted);
|
||||
opacity: 0.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dreams-diary__empty-text {
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dreams-diary__empty-hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dreams-diary__error {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.dreams {
|
||||
min-height: calc(100vh - 96px);
|
||||
}
|
||||
|
||||
.dreams__stats {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.dreams__phase-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.dreams__phase-bar-phases {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dreams-diary {
|
||||
padding: 32px 16px 48px;
|
||||
}
|
||||
|
||||
.dreams-diary__entry {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ import {
|
||||
rotateDeviceToken,
|
||||
} from "./controllers/devices.ts";
|
||||
import {
|
||||
loadDreamDiary,
|
||||
loadDreamingStatus,
|
||||
updateDreamingMode,
|
||||
type DreamingMode,
|
||||
updateDreamingEnabled,
|
||||
} from "./controllers/dreaming.ts";
|
||||
import {
|
||||
loadExecApprovals,
|
||||
@@ -149,34 +149,22 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts"));
|
||||
const lazySessions = createLazy(() => import("./views/sessions.ts"));
|
||||
const lazySkills = createLazy(() => import("./views/skills.ts"));
|
||||
const lazyDreamingView = createLazy(() => import("./views/dreaming.ts"));
|
||||
const DREAMING_MODE_OPTIONS: Array<{ id: DreamingMode; label: string; detail: string }> = [
|
||||
{ id: "off", label: "Off", detail: "no automatic promotion" },
|
||||
{ id: "core", label: "Core", detail: "nightly durable consolidation" },
|
||||
{ id: "rem", label: "REM", detail: "every 6 hours, stricter" },
|
||||
{ id: "deep", label: "Deep", detail: "every 12 hours, conservative" },
|
||||
];
|
||||
|
||||
function resolveConfiguredDreaming(configValue: Record<string, unknown> | null): {
|
||||
mode: DreamingMode;
|
||||
enabled: boolean;
|
||||
} {
|
||||
const fallback: DreamingMode = "off";
|
||||
if (!configValue) {
|
||||
return { mode: fallback };
|
||||
return {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
const plugins = configValue.plugins as Record<string, unknown> | undefined;
|
||||
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
||||
const memoryCore = entries?.["memory-core"] as Record<string, unknown> | undefined;
|
||||
const config = memoryCore?.config as Record<string, unknown> | undefined;
|
||||
const dreaming = config?.dreaming as Record<string, unknown> | undefined;
|
||||
const modeRaw = typeof dreaming?.mode === "string" ? dreaming.mode.trim().toLowerCase() : "";
|
||||
if (modeRaw === "off" || modeRaw === "core" || modeRaw === "rem" || modeRaw === "deep") {
|
||||
return { mode: modeRaw };
|
||||
}
|
||||
if (typeof dreaming?.enabled === "boolean") {
|
||||
return { mode: dreaming.enabled ? "core" : "off" };
|
||||
}
|
||||
return {
|
||||
mode: fallback,
|
||||
enabled: typeof dreaming?.enabled === "boolean" ? dreaming.enabled : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,20 +179,12 @@ function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
|
||||
}
|
||||
|
||||
function resolveDreamingNextCycle(
|
||||
status: {
|
||||
phases: {
|
||||
deep?: { enabled: boolean; nextRunAtMs?: number };
|
||||
light?: { enabled: boolean; nextRunAtMs?: number };
|
||||
rem?: { enabled: boolean; nextRunAtMs?: number };
|
||||
};
|
||||
} | null,
|
||||
status: { phases: Record<string, { enabled: boolean; nextRunAtMs?: number }> } | null,
|
||||
): string | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
const phases = [status.phases.deep, status.phases.light, status.phases.rem];
|
||||
const nextRunAtMs = phases
|
||||
.filter((phase): phase is { enabled: boolean; nextRunAtMs?: number } => Boolean(phase))
|
||||
const nextRunAtMs = Object.values(status.phases)
|
||||
.filter((phase) => phase.enabled && typeof phase.nextRunAtMs === "number")
|
||||
.map((phase) => phase.nextRunAtMs as number)
|
||||
.toSorted((a, b) => a - b)[0];
|
||||
@@ -397,17 +377,19 @@ export function renderApp(state: AppViewState) {
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const configuredDreaming = resolveConfiguredDreaming(configValue);
|
||||
const dreamingMode = configuredDreaming.mode;
|
||||
const dreamingOn = dreamingMode !== "off";
|
||||
const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled;
|
||||
const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus);
|
||||
const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving;
|
||||
const refreshDreamingStatus = () => loadDreamingStatus(state);
|
||||
const applyDreamingMode = (mode: DreamingMode) => {
|
||||
if (state.dreamingModeSaving || dreamingMode === mode) {
|
||||
const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading;
|
||||
const refreshDreaming = () => {
|
||||
void Promise.all([loadDreamingStatus(state), loadDreamDiary(state)]);
|
||||
};
|
||||
const applyDreamingEnabled = (enabled: boolean) => {
|
||||
if (state.dreamingModeSaving || dreamingOn === enabled) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const updated = await updateDreamingMode(state, mode);
|
||||
const updated = await updateDreamingEnabled(state, enabled);
|
||||
if (!updated) {
|
||||
return;
|
||||
}
|
||||
@@ -714,37 +696,23 @@ export function renderApp(state: AppViewState) {
|
||||
<div class="dreaming-header-controls">
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${dreamingLoading}
|
||||
@click=${refreshDreamingStatus}
|
||||
?disabled=${dreamingLoading || state.dreamDiaryLoading}
|
||||
@click=${refreshDreaming}
|
||||
>
|
||||
${state.dreamingStatusLoading ? "Refreshing…" : "Refresh"}
|
||||
${dreamingRefreshLoading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
<div
|
||||
class="dreaming-header-controls__modes"
|
||||
role="group"
|
||||
aria-label="Dreaming mode"
|
||||
<button
|
||||
class="dreams__phase-toggle ${dreamingOn
|
||||
? "dreams__phase-toggle--on"
|
||||
: ""}"
|
||||
?disabled=${dreamingLoading}
|
||||
@click=${() => applyDreamingEnabled(!dreamingOn)}
|
||||
>
|
||||
${DREAMING_MODE_OPTIONS.map(
|
||||
(option) => html`
|
||||
<button
|
||||
class="dreaming-header-controls__mode ${dreamingMode === option.id
|
||||
? "dreaming-header-controls__mode--active"
|
||||
: ""}"
|
||||
?disabled=${dreamingLoading}
|
||||
title=${option.detail}
|
||||
aria-label=${`Set dreaming mode to ${option.label}`}
|
||||
@click=${() => applyDreamingMode(option.id)}
|
||||
>
|
||||
<span class="dreaming-header-controls__mode-label"
|
||||
>${option.label}</span
|
||||
>
|
||||
<span class="dreaming-header-controls__mode-detail"
|
||||
>${option.detail}</span
|
||||
>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<span class="dreams__phase-toggle-dot"></span>
|
||||
<span class="dreams__phase-toggle-label"
|
||||
>${dreamingOn ? "Dreaming On" : "Dreaming Off"}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -2134,19 +2102,23 @@ export function renderApp(state: AppViewState) {
|
||||
? lazyRender(lazyDreamingView, (m) =>
|
||||
m.renderDreaming({
|
||||
active: dreamingOn,
|
||||
mode: dreamingMode,
|
||||
shortTermCount: state.dreamingStatus?.shortTermCount ?? 0,
|
||||
longTermCount: state.dreamingStatus?.promotedTotal ?? 0,
|
||||
promotedCount: state.dreamingStatus?.promotedToday ?? 0,
|
||||
dreamingOf: null,
|
||||
nextCycle: dreamingNextCycle,
|
||||
timezone: state.dreamingStatus?.timezone ?? null,
|
||||
modes: DREAMING_MODE_OPTIONS,
|
||||
statusLoading: state.dreamingStatusLoading,
|
||||
statusError: state.dreamingStatusError,
|
||||
modeSaving: state.dreamingModeSaving,
|
||||
onRefresh: refreshDreamingStatus,
|
||||
onSelectMode: applyDreamingMode,
|
||||
dreamDiaryLoading: state.dreamDiaryLoading,
|
||||
dreamDiaryError: state.dreamDiaryError,
|
||||
dreamDiaryPath: state.dreamDiaryPath,
|
||||
dreamDiaryContent: state.dreamDiaryContent,
|
||||
onRefresh: refreshDreaming,
|
||||
onRefreshDiary: () => loadDreamDiary(state),
|
||||
onToggleEnabled: applyDreamingEnabled,
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
}),
|
||||
)
|
||||
: nothing}
|
||||
|
||||
@@ -69,6 +69,10 @@ type SettingsHost = {
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: null;
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
};
|
||||
|
||||
function setTestWindowUrl(urlString: string) {
|
||||
@@ -153,6 +157,10 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
dreamingStatusError: null,
|
||||
dreamingStatus: null,
|
||||
dreamingModeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: null,
|
||||
dreamDiaryContent: null,
|
||||
});
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
||||
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
|
||||
import { loadDebug } from "./controllers/debug.ts";
|
||||
import { loadDevices } from "./controllers/devices.ts";
|
||||
import { loadDreamingStatus } from "./controllers/dreaming.ts";
|
||||
import { loadDreamDiary, loadDreamingStatus } from "./controllers/dreaming.ts";
|
||||
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
@@ -64,6 +64,10 @@ type SettingsHost = {
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
};
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
@@ -274,7 +278,10 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
}
|
||||
if (host.tab === "dreams") {
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await loadDreamingStatus(host as unknown as OpenClawApp);
|
||||
await Promise.all([
|
||||
loadDreamingStatus(host as unknown as OpenClawApp),
|
||||
loadDreamDiary(host as unknown as OpenClawApp),
|
||||
]);
|
||||
}
|
||||
if (host.tab === "chat") {
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
|
||||
@@ -125,6 +125,10 @@ export type AppViewState = {
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
|
||||
@@ -225,6 +225,10 @@ export class OpenClawApp extends LitElement {
|
||||
@state() dreamingStatusError: string | null = null;
|
||||
@state() dreamingStatus: DreamingStatus | null = null;
|
||||
@state() dreamingModeSaving = false;
|
||||
@state() dreamDiaryLoading = false;
|
||||
@state() dreamDiaryError: string | null = null;
|
||||
@state() dreamDiaryPath: string | null = null;
|
||||
@state() dreamDiaryContent: string | null = null;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadDreamingStatus, updateDreamingMode, type DreamingState } from "./dreaming.ts";
|
||||
import {
|
||||
loadDreamDiary,
|
||||
loadDreamingStatus,
|
||||
updateDreamingEnabled,
|
||||
type DreamingState,
|
||||
} from "./dreaming.ts";
|
||||
|
||||
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
@@ -14,6 +19,10 @@ function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn
|
||||
dreamingStatusError: null,
|
||||
dreamingStatus: null,
|
||||
dreamingModeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: null,
|
||||
dreamDiaryContent: null,
|
||||
lastError: null,
|
||||
};
|
||||
return { state, request };
|
||||
@@ -24,7 +33,6 @@ describe("dreaming controller", () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({
|
||||
dreaming: {
|
||||
mode: "core",
|
||||
enabled: true,
|
||||
timezone: "America/Los_Angeles",
|
||||
verboseLogging: false,
|
||||
@@ -72,7 +80,6 @@ describe("dreaming controller", () => {
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.status", {});
|
||||
expect(state.dreamingStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: "core",
|
||||
enabled: true,
|
||||
shortTermCount: 8,
|
||||
promotedToday: 2,
|
||||
@@ -88,18 +95,17 @@ describe("dreaming controller", () => {
|
||||
expect(state.dreamingStatusError).toBeNull();
|
||||
});
|
||||
|
||||
it("patches config to update dreaming mode", async () => {
|
||||
it("patches config to update global dreaming enablement", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({ ok: true });
|
||||
|
||||
const ok = await updateDreamingMode(state, "deep");
|
||||
const ok = await updateDreamingEnabled(state, false);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.objectContaining({
|
||||
baseHash: "hash-1",
|
||||
raw: expect.stringContaining('"mode":"deep"'),
|
||||
sessionKey: "main",
|
||||
}),
|
||||
);
|
||||
@@ -111,10 +117,50 @@ describe("dreaming controller", () => {
|
||||
const { state, request } = createState();
|
||||
state.configSnapshot = {};
|
||||
|
||||
const ok = await updateDreamingMode(state, "core");
|
||||
const ok = await updateDreamingEnabled(state, true);
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.dreamingStatusError).toContain("Config hash missing");
|
||||
});
|
||||
|
||||
it("loads dream diary content", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({
|
||||
found: true,
|
||||
path: "DREAMS.md",
|
||||
content: "## Dream Diary\n- recurring glacier thoughts",
|
||||
});
|
||||
|
||||
await loadDreamDiary(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("doctor.memory.dreamDiary", {});
|
||||
expect(state.dreamDiaryPath).toBe("DREAMS.md");
|
||||
expect(state.dreamDiaryContent).toContain("glacier");
|
||||
expect(state.dreamDiaryError).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing dream diary without error", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockResolvedValue({
|
||||
found: false,
|
||||
path: "DREAMS.md",
|
||||
});
|
||||
|
||||
await loadDreamDiary(state);
|
||||
|
||||
expect(state.dreamDiaryPath).toBe("DREAMS.md");
|
||||
expect(state.dreamDiaryContent).toBeNull();
|
||||
expect(state.dreamDiaryError).toBeNull();
|
||||
});
|
||||
|
||||
it("records dream diary request errors", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockRejectedValue(new Error("dream diary read failed"));
|
||||
|
||||
await loadDreamDiary(state);
|
||||
|
||||
expect(state.dreamDiaryError).toContain("dream diary read failed");
|
||||
expect(state.dreamDiaryLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ConfigSnapshot } from "../types.ts";
|
||||
|
||||
export type DreamingMode = "off" | "core" | "rem" | "deep";
|
||||
export type DreamingPhaseId = "light" | "deep" | "rem";
|
||||
const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md";
|
||||
|
||||
type DreamingPhaseStatusBase = {
|
||||
enabled: boolean;
|
||||
@@ -31,7 +32,6 @@ type RemDreamingStatus = DreamingPhaseStatusBase & {
|
||||
};
|
||||
|
||||
export type DreamingStatus = {
|
||||
mode: DreamingMode;
|
||||
enabled: boolean;
|
||||
timezone?: string;
|
||||
verboseLogging: boolean;
|
||||
@@ -53,6 +53,12 @@ type DoctorMemoryStatusPayload = {
|
||||
dreaming?: unknown;
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamDiaryPayload = {
|
||||
found?: unknown;
|
||||
path?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export type DreamingState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
@@ -62,6 +68,10 @@ export type DreamingState = {
|
||||
dreamingStatusError: string | null;
|
||||
dreamingStatus: DreamingStatus | null;
|
||||
dreamingModeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
@@ -106,19 +116,6 @@ function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] {
|
||||
return "inline";
|
||||
}
|
||||
|
||||
function normalizeDreamingMode(value: unknown): DreamingMode | undefined {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "rem" ||
|
||||
normalized === "deep"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeNextRun(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
@@ -146,13 +143,9 @@ function normalizeDreamingStatus(raw: unknown): DreamingStatus | null {
|
||||
const timezone = normalizeTrimmedString(record.timezone);
|
||||
const storePath = normalizeTrimmedString(record.storePath);
|
||||
const storeError = normalizeTrimmedString(record.storeError);
|
||||
const explicitMode = normalizeDreamingMode(record.mode);
|
||||
const enabled = normalizeBoolean(record.enabled, false);
|
||||
const mode = explicitMode ?? (enabled ? "core" : "off");
|
||||
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
enabled: normalizeBoolean(record.enabled, false),
|
||||
...(timezone ? { timezone } : {}),
|
||||
verboseLogging: normalizeBoolean(record.verboseLogging, false),
|
||||
storageMode: normalizeStorageMode(record.storageMode),
|
||||
@@ -208,6 +201,33 @@ export async function loadDreamingStatus(state: DreamingState): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDreamDiary(state: DreamingState): Promise<void> {
|
||||
if (!state.client || !state.connected || state.dreamDiaryLoading) {
|
||||
return;
|
||||
}
|
||||
state.dreamDiaryLoading = true;
|
||||
state.dreamDiaryError = null;
|
||||
try {
|
||||
const payload = await state.client.request<DoctorMemoryDreamDiaryPayload>(
|
||||
"doctor.memory.dreamDiary",
|
||||
{},
|
||||
);
|
||||
const path = normalizeTrimmedString(payload?.path) ?? DEFAULT_DREAM_DIARY_PATH;
|
||||
const found = payload?.found === true;
|
||||
if (found) {
|
||||
state.dreamDiaryPath = path;
|
||||
state.dreamDiaryContent = typeof payload?.content === "string" ? payload.content : "";
|
||||
} else {
|
||||
state.dreamDiaryPath = path;
|
||||
state.dreamDiaryContent = null;
|
||||
}
|
||||
} catch (err) {
|
||||
state.dreamDiaryError = String(err);
|
||||
} finally {
|
||||
state.dreamDiaryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDreamingPatch(
|
||||
state: DreamingState,
|
||||
patch: Record<string, unknown>,
|
||||
@@ -247,13 +267,6 @@ async function writeDreamingPatch(
|
||||
export async function updateDreamingEnabled(
|
||||
state: DreamingState,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
return updateDreamingMode(state, enabled ? "core" : "off");
|
||||
}
|
||||
|
||||
export async function updateDreamingMode(
|
||||
state: DreamingState,
|
||||
mode: DreamingMode,
|
||||
): Promise<boolean> {
|
||||
const ok = await writeDreamingPatch(state, {
|
||||
plugins: {
|
||||
@@ -261,7 +274,7 @@ export async function updateDreamingMode(
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -271,8 +284,7 @@ export async function updateDreamingMode(
|
||||
if (ok && state.dreamingStatus) {
|
||||
state.dreamingStatus = {
|
||||
...state.dreamingStatus,
|
||||
mode,
|
||||
enabled: mode !== "off",
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
return ok;
|
||||
|
||||
@@ -130,6 +130,8 @@ describe("tabFromPath", () => {
|
||||
expect(tabFromPath("/chat")).toBe("chat");
|
||||
expect(tabFromPath("/overview")).toBe("overview");
|
||||
expect(tabFromPath("/sessions")).toBe("sessions");
|
||||
expect(tabFromPath("/dreaming")).toBe("dreams");
|
||||
expect(tabFromPath("/dreams")).toBe("dreams");
|
||||
});
|
||||
|
||||
it("returns chat for root path", () => {
|
||||
@@ -159,6 +161,8 @@ describe("inferBasePathFromPathname", () => {
|
||||
it("returns empty string for direct tab path", () => {
|
||||
expect(inferBasePathFromPathname("/chat")).toBe("");
|
||||
expect(inferBasePathFromPathname("/overview")).toBe("");
|
||||
expect(inferBasePathFromPathname("/dreaming")).toBe("");
|
||||
expect(inferBasePathFromPathname("/dreams")).toBe("");
|
||||
});
|
||||
|
||||
it("infers base path from nested paths", () => {
|
||||
|
||||
@@ -66,7 +66,14 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
dreams: "/dreaming",
|
||||
};
|
||||
|
||||
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
|
||||
const PATH_ALIASES: Record<string, Tab> = {
|
||||
"/dreams": "dreams",
|
||||
};
|
||||
|
||||
const PATH_TO_TAB = new Map<string, Tab>([
|
||||
...Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab] as const),
|
||||
...Object.entries(PATH_ALIASES),
|
||||
]);
|
||||
|
||||
export function normalizeBasePath(basePath: string): string {
|
||||
if (!basePath) {
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderDreaming, type DreamingProps } from "./dreaming.ts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDreaming, setDreamSubTab, type DreamingProps } from "./dreaming.ts";
|
||||
|
||||
function buildProps(overrides?: Partial<DreamingProps>): DreamingProps {
|
||||
return {
|
||||
active: true,
|
||||
mode: "core",
|
||||
shortTermCount: 47,
|
||||
longTermCount: 182,
|
||||
promotedCount: 12,
|
||||
dreamingOf: null,
|
||||
nextCycle: "4:00 AM",
|
||||
timezone: "America/Los_Angeles",
|
||||
modes: [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
detail: "no automatic promotion",
|
||||
},
|
||||
{
|
||||
id: "core",
|
||||
label: "Core",
|
||||
detail: "nightly durable consolidation",
|
||||
},
|
||||
{
|
||||
id: "deep",
|
||||
label: "Deep",
|
||||
detail: "every 12 hours, conservative",
|
||||
},
|
||||
{
|
||||
id: "rem",
|
||||
label: "REM",
|
||||
detail: "every 6 hours, stricter",
|
||||
},
|
||||
],
|
||||
statusLoading: false,
|
||||
statusError: null,
|
||||
modeSaving: false,
|
||||
dreamDiaryLoading: false,
|
||||
dreamDiaryError: null,
|
||||
dreamDiaryPath: "DREAMS.md",
|
||||
dreamDiaryContent:
|
||||
"# Dream Diary\n\n<!-- openclaw:dreaming:diary:start -->\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n<!-- openclaw:dreaming:diary:end -->",
|
||||
onRefresh: () => {},
|
||||
onSelectMode: () => {},
|
||||
onRefreshDiary: () => {},
|
||||
onToggleEnabled: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -95,21 +78,21 @@ describe("dreaming view", () => {
|
||||
});
|
||||
|
||||
it("shows custom dreamingOf text when provided", () => {
|
||||
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats…" }));
|
||||
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" }));
|
||||
const text = container.querySelector(".dreams__bubble-text");
|
||||
expect(text?.textContent).toBe("reindexing old chats…");
|
||||
expect(text?.textContent).toBe("reindexing old chats\u2026");
|
||||
});
|
||||
|
||||
it("shows active status label when active", () => {
|
||||
const container = renderInto(buildProps({ active: true }));
|
||||
const label = container.querySelector(".dreams__status-label");
|
||||
expect(label?.textContent).toContain("Dreaming Active");
|
||||
expect(label?.textContent).toBe("Dreaming Active");
|
||||
});
|
||||
|
||||
it("shows idle status label when inactive", () => {
|
||||
const container = renderInto(buildProps({ active: false }));
|
||||
const label = container.querySelector(".dreams__status-label");
|
||||
expect(label?.textContent).toContain("Dreaming Idle");
|
||||
expect(label?.textContent).toBe("Dreaming Idle");
|
||||
});
|
||||
|
||||
it("applies idle class when not active", () => {
|
||||
@@ -123,12 +106,6 @@ describe("dreaming view", () => {
|
||||
expect(detail?.textContent).toContain("4:00 AM");
|
||||
});
|
||||
|
||||
it("renders mode controls", () => {
|
||||
const container = renderInto(buildProps());
|
||||
expect(container.querySelector(".dreams__controls")).not.toBeNull();
|
||||
expect(container.querySelectorAll(".dreams__phase").length).toBe(4);
|
||||
});
|
||||
|
||||
it("renders control error when present", () => {
|
||||
const container = renderInto(buildProps({ statusError: "patch failed" }));
|
||||
expect(container.querySelector(".dreams__controls-error")?.textContent).toContain(
|
||||
@@ -136,12 +113,55 @@ describe("dreaming view", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("wires mode selection callbacks", () => {
|
||||
const onSelectMode = vi.fn();
|
||||
const container = renderInto(buildProps({ onSelectMode }));
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".dreams__phase .btn")?.click();
|
||||
|
||||
expect(onSelectMode).toHaveBeenCalled();
|
||||
it("renders sub-tab navigation", () => {
|
||||
const container = renderInto(buildProps());
|
||||
const tabs = container.querySelectorAll(".dreams__tab");
|
||||
expect(tabs.length).toBe(2);
|
||||
expect(tabs[0]?.textContent).toContain("Scene");
|
||||
expect(tabs[1]?.textContent).toContain("Diary");
|
||||
});
|
||||
|
||||
it("renders dream diary with parsed entry on diary tab", () => {
|
||||
setDreamSubTab("diary");
|
||||
const container = renderInto(buildProps());
|
||||
const title = container.querySelector(".dreams-diary__title");
|
||||
expect(title?.textContent).toContain("Dream Diary");
|
||||
|
||||
const entry = container.querySelector(".dreams-diary__entry");
|
||||
expect(entry).not.toBeNull();
|
||||
const date = container.querySelector(".dreams-diary__date");
|
||||
expect(date?.textContent).toContain("April 5, 2026");
|
||||
const body = container.querySelector(".dreams-diary__para");
|
||||
expect(body?.textContent).toContain("forgotten endpoints");
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
it("shows empty diary state when no diary content exists", () => {
|
||||
setDreamSubTab("diary");
|
||||
const container = renderInto(buildProps({ dreamDiaryContent: null }));
|
||||
expect(container.querySelector(".dreams-diary__empty")).not.toBeNull();
|
||||
expect(container.querySelector(".dreams-diary__empty-text")?.textContent).toContain(
|
||||
"No dreams yet",
|
||||
);
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
it("shows diary error message when diary load fails", () => {
|
||||
setDreamSubTab("diary");
|
||||
const container = renderInto(buildProps({ dreamDiaryError: "read failed" }));
|
||||
expect(container.querySelector(".dreams-diary__error")?.textContent).toContain("read failed");
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
it("renders page navigation for diary entries", () => {
|
||||
setDreamSubTab("diary");
|
||||
const container = renderInto(buildProps());
|
||||
const pageInfo = container.querySelector(".dreams-diary__page");
|
||||
expect(pageInfo?.textContent).toContain("1 / 1");
|
||||
const navBtns = container.querySelectorAll(".dreams-diary__nav-btn");
|
||||
expect(navBtns.length).toBe(2);
|
||||
setDreamSubTab("scene");
|
||||
});
|
||||
|
||||
// Toggle lives in the page header (app-render.ts), not inside the dreaming view.
|
||||
});
|
||||
|
||||
@@ -1,51 +1,121 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { DreamingMode } from "../controllers/dreaming.ts";
|
||||
|
||||
// ── Diary entry parser ─────────────────────────────────────────────────
|
||||
|
||||
type DiaryEntry = {
|
||||
date: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const DIARY_START_RE = /<!--\s*openclaw:dreaming:diary:start\s*-->/;
|
||||
const DIARY_END_RE = /<!--\s*openclaw:dreaming:diary:end\s*-->/;
|
||||
|
||||
function parseDiaryEntries(raw: string): DiaryEntry[] {
|
||||
// Extract content between diary markers, or use full content.
|
||||
let content = raw;
|
||||
const startMatch = DIARY_START_RE.exec(raw);
|
||||
const endMatch = DIARY_END_RE.exec(raw);
|
||||
if (startMatch && endMatch && endMatch.index > startMatch.index) {
|
||||
content = raw.slice(startMatch.index + startMatch[0].length, endMatch.index);
|
||||
}
|
||||
|
||||
const entries: DiaryEntry[] = [];
|
||||
// Split on --- separators.
|
||||
const blocks = content.split(/\n---\n/).filter((b) => b.trim().length > 0);
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split("\n");
|
||||
let date = "";
|
||||
const bodyLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Date lines are wrapped in *asterisks* like: *April 5, 2026, 3:00 AM*
|
||||
if (!date && trimmed.startsWith("*") && trimmed.endsWith("*") && trimmed.length > 2) {
|
||||
date = trimmed.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
// Skip heading lines and HTML comments.
|
||||
if (trimmed.startsWith("#") || trimmed.startsWith("<!--")) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.length > 0) {
|
||||
bodyLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyLines.length > 0) {
|
||||
entries.push({ date, body: bodyLines.join("\n") });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export type DreamingProps = {
|
||||
active: boolean;
|
||||
mode: DreamingMode;
|
||||
shortTermCount: number;
|
||||
longTermCount: number;
|
||||
promotedCount: number;
|
||||
dreamingOf: string | null;
|
||||
nextCycle: string | null;
|
||||
timezone: string | null;
|
||||
modes: Array<{
|
||||
id: DreamingMode;
|
||||
label: string;
|
||||
detail: string;
|
||||
}>;
|
||||
statusLoading: boolean;
|
||||
statusError: string | null;
|
||||
modeSaving: boolean;
|
||||
dreamDiaryLoading: boolean;
|
||||
dreamDiaryError: string | null;
|
||||
dreamDiaryPath: string | null;
|
||||
dreamDiaryContent: string | null;
|
||||
onRefresh: () => void;
|
||||
onSelectMode: (mode: DreamingMode) => void;
|
||||
onRefreshDiary: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
const DREAM_PHRASES = [
|
||||
"consolidating memories…",
|
||||
"tidying the knowledge graph…",
|
||||
"replaying today's conversations…",
|
||||
"weaving short-term into long-term…",
|
||||
"defragmenting the mind palace…",
|
||||
"filing away loose thoughts…",
|
||||
"connecting distant dots…",
|
||||
"composting old context windows…",
|
||||
"alphabetizing the subconscious…",
|
||||
"promoting promising hunches…",
|
||||
"forgetting what doesn't matter…",
|
||||
"dreaming in embeddings…",
|
||||
"reorganizing the memory attic…",
|
||||
"softly indexing the day…",
|
||||
"nurturing fledgling insights…",
|
||||
"simmering half-formed ideas…",
|
||||
"whispering to the vector store…",
|
||||
"consolidating memories\u2026",
|
||||
"tidying the knowledge graph\u2026",
|
||||
"replaying today's conversations\u2026",
|
||||
"weaving short-term into long-term\u2026",
|
||||
"defragmenting the mind palace\u2026",
|
||||
"filing away loose thoughts\u2026",
|
||||
"connecting distant dots\u2026",
|
||||
"composting old context windows\u2026",
|
||||
"alphabetizing the subconscious\u2026",
|
||||
"promoting promising hunches\u2026",
|
||||
"forgetting what doesn't matter\u2026",
|
||||
"dreaming in embeddings\u2026",
|
||||
"reorganizing the memory attic\u2026",
|
||||
"softly indexing the day\u2026",
|
||||
"nurturing fledgling insights\u2026",
|
||||
"simmering half-formed ideas\u2026",
|
||||
"whispering to the vector store\u2026",
|
||||
];
|
||||
|
||||
let _dreamIndex = Math.floor(Math.random() * DREAM_PHRASES.length);
|
||||
let _dreamLastSwap = 0;
|
||||
const DREAM_SWAP_MS = 6_000;
|
||||
|
||||
// ── Sub-tab state ─────────────────────────────────────────────────────
|
||||
|
||||
type DreamSubTab = "scene" | "diary";
|
||||
let _subTab: DreamSubTab = "scene";
|
||||
|
||||
export function setDreamSubTab(tab: DreamSubTab): void {
|
||||
_subTab = tab;
|
||||
}
|
||||
|
||||
// ── Diary pagination state ─────────────────────────────────────────────
|
||||
|
||||
let _diaryPage = 0;
|
||||
let _diaryEntryCount = 0;
|
||||
|
||||
/** Navigate to a specific diary page. Triggers a re-render via Lit's reactive cycle. */
|
||||
export function setDiaryPage(page: number): void {
|
||||
_diaryPage = Math.max(0, Math.min(page, Math.max(0, _diaryEntryCount - 1)));
|
||||
}
|
||||
|
||||
function currentDreamPhrase(): string {
|
||||
const now = Date.now();
|
||||
if (now - _dreamLastSwap > DREAM_SWAP_MS) {
|
||||
@@ -116,6 +186,38 @@ export function renderDreaming(props: DreamingProps) {
|
||||
const idle = !props.active;
|
||||
const dreamText = props.dreamingOf ?? currentDreamPhrase();
|
||||
|
||||
return html`
|
||||
<div class="dreams-page">
|
||||
<!-- ── Sub-tab bar ── -->
|
||||
<nav class="dreams__tabs">
|
||||
<button
|
||||
class="dreams__tab ${_subTab === "scene" ? "dreams__tab--active" : ""}"
|
||||
@click=${() => {
|
||||
_subTab = "scene";
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
>
|
||||
Scene
|
||||
</button>
|
||||
<button
|
||||
class="dreams__tab ${_subTab === "diary" ? "dreams__tab--active" : ""}"
|
||||
@click=${() => {
|
||||
_subTab = "diary";
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
>
|
||||
Diary
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
${_subTab === "scene" ? renderScene(props, idle, dreamText) : renderDiarySection(props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Scene renderer ────────────────────────────────────────────────────
|
||||
|
||||
function renderScene(props: DreamingProps, idle: boolean, dreamText: string) {
|
||||
return html`
|
||||
<section class="dreams ${idle ? "dreams--idle" : ""}">
|
||||
${STARS.map(
|
||||
@@ -160,13 +262,13 @@ export function renderDreaming(props: DreamingProps) {
|
||||
|
||||
<div class="dreams__status">
|
||||
<span class="dreams__status-label"
|
||||
>${props.active ? "Dreaming Active" : "Dreaming Idle"} · ${props.mode.toUpperCase()}</span
|
||||
>${props.active ? "Dreaming Active" : "Dreaming Idle"}</span
|
||||
>
|
||||
<div class="dreams__status-detail">
|
||||
<div class="dreams__status-dot"></div>
|
||||
<span>
|
||||
${props.promotedCount} promoted
|
||||
${props.nextCycle ? html`· next run ${props.nextCycle}` : nothing}
|
||||
${props.nextCycle ? html`· next sweep ${props.nextCycle}` : nothing}
|
||||
${props.timezone ? html`· ${props.timezone}` : nothing}
|
||||
</span>
|
||||
</div>
|
||||
@@ -195,63 +297,133 @@ export function renderDreaming(props: DreamingProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dreams__controls">
|
||||
<div class="dreams__controls-head">
|
||||
<div>
|
||||
<div class="dreams__controls-title">Dreaming Modes</div>
|
||||
<div class="dreams__controls-subtitle">
|
||||
Pick a cadence and threshold profile for durable promotion.
|
||||
</div>
|
||||
</div>
|
||||
<div class="dreams__controls-actions">
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving}
|
||||
@click=${props.onRefresh}
|
||||
>
|
||||
${props.statusLoading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dreams__phase-grid">
|
||||
${props.modes.map(
|
||||
(mode) => html`
|
||||
<article
|
||||
class="dreams__phase ${props.mode === mode.id ? "dreams__phase--active" : ""}"
|
||||
>
|
||||
<div class="dreams__phase-top">
|
||||
<div>
|
||||
<div class="dreams__phase-label">${mode.label}</div>
|
||||
<div class="dreams__phase-detail">${mode.detail}</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving || props.mode === mode.id}
|
||||
@click=${() => props.onSelectMode(mode.id)}
|
||||
>
|
||||
${props.mode === mode.id ? "Active" : "Set"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="dreams__phase-meta">
|
||||
<span>${props.mode === mode.id ? "selected" : "available"}</span>
|
||||
<span>
|
||||
${mode.id === "off"
|
||||
? "no background runs"
|
||||
: mode.id === "core"
|
||||
? "nightly cadence"
|
||||
: mode.id === "deep"
|
||||
? "every 12 hours"
|
||||
: "every 6 hours"}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${props.statusError
|
||||
? html`<div class="dreams__controls-error">${props.statusError}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${props.statusError
|
||||
? html`<div class="dreams__controls-error">${props.statusError}</div>`
|
||||
: nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Diary section renderer ────────────────────────────────────────────
|
||||
|
||||
function renderDiarySection(props: DreamingProps) {
|
||||
if (props.dreamDiaryError) {
|
||||
return html`
|
||||
<section class="dreams-diary">
|
||||
<div class="dreams-diary__error">${props.dreamDiaryError}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
if (typeof props.dreamDiaryContent !== "string") {
|
||||
return html`
|
||||
<section class="dreams-diary">
|
||||
<div class="dreams-diary__empty">
|
||||
<div class="dreams-diary__empty-moon">
|
||||
<svg viewBox="0 0 32 32" fill="none" width="32" height="32">
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M20 8a10 10 0 0 1 0 16 10 10 0 1 0 0-16z"
|
||||
fill="currentColor"
|
||||
opacity="0.08"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dreams-diary__empty-text">No dreams yet</div>
|
||||
<div class="dreams-diary__empty-hint">
|
||||
Dreams will appear here after the first dreaming cycle runs.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const entries = parseDiaryEntries(props.dreamDiaryContent);
|
||||
_diaryEntryCount = entries.length;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return html`
|
||||
<section class="dreams-diary">
|
||||
<div class="dreams-diary__empty">
|
||||
<div class="dreams-diary__empty-text">The diary is waiting</div>
|
||||
<div class="dreams-diary__empty-hint">
|
||||
Narrative entries will appear after the next dreaming cycle.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show most recent entries first (reverse chronological).
|
||||
const reversed = [...entries].toReversed();
|
||||
// Clamp page.
|
||||
const page = Math.max(0, Math.min(_diaryPage, reversed.length - 1));
|
||||
const entry = reversed[page];
|
||||
const hasPrev = page > 0;
|
||||
const hasNext = page < reversed.length - 1;
|
||||
|
||||
return html`
|
||||
<section class="dreams-diary">
|
||||
<div class="dreams-diary__header">
|
||||
<span class="dreams-diary__title">Dream Diary</span>
|
||||
<div class="dreams-diary__nav">
|
||||
<button
|
||||
class="dreams-diary__nav-btn"
|
||||
?disabled=${!hasNext}
|
||||
@click=${() => {
|
||||
setDiaryPage(page + 1);
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
title="Older"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="dreams-diary__page">${page + 1} / ${reversed.length}</span>
|
||||
<button
|
||||
class="dreams-diary__nav-btn"
|
||||
?disabled=${!hasPrev}
|
||||
@click=${() => {
|
||||
setDiaryPage(page - 1);
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
title="Newer"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--subtle btn--sm"
|
||||
?disabled=${props.modeSaving || props.dreamDiaryLoading}
|
||||
@click=${() => {
|
||||
_diaryPage = 0;
|
||||
props.onRefreshDiary();
|
||||
}}
|
||||
>
|
||||
${props.dreamDiaryLoading ? "\u2026" : "Reload"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<article class="dreams-diary__entry" key="${page}">
|
||||
<div class="dreams-diary__accent"></div>
|
||||
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
|
||||
<div class="dreams-diary__prose">
|
||||
${entry.body
|
||||
.split("\n")
|
||||
.map(
|
||||
(para, i) =>
|
||||
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
|
||||
${para}
|
||||
</p>`,
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user