diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 53ba05a3edf..628b58fb3fe 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -4,6 +4,7 @@ import type { ChatCommandDefinition, CommandCategory, CommandScope, + CommandTier, } from "./commands-registry.types.js"; import { listThinkingLevels } from "./thinking.js"; @@ -20,6 +21,8 @@ type DefineChatCommandInput = { textAliases?: string[]; scope?: CommandScope; category?: CommandCategory; + /** Progressive disclosure tier. Defaults to "standard". */ + tier?: CommandTier; }; export function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition { @@ -42,6 +45,7 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD textAliases: aliases, scope, category: command.category, + tier: command.tier, }; } @@ -129,6 +133,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show available commands.", textAlias: "/help", category: "status", + tier: "essential", }), defineChatCommand({ key: "commands", @@ -136,6 +141,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "List all slash commands.", textAlias: "/commands", category: "status", + tier: "power", }), defineChatCommand({ key: "tools", @@ -152,6 +158,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { }, ], argsMenu: "auto", + tier: "standard", }), defineChatCommand({ key: "skill", @@ -159,6 +166,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Run a skill by name.", textAlias: "/skill", category: "tools", + tier: "standard", args: [ { name: "name", @@ -180,6 +188,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show current status.", textAlias: "/status", category: "status", + tier: "essential", }), defineChatCommand({ key: "tasks", @@ -187,6 +196,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "List background tasks for this session.", textAlias: "/tasks", category: "status", + tier: "standard", }), defineChatCommand({ key: "allowlist", @@ -195,6 +205,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { acceptsArgs: true, scope: "text", category: "management", + tier: "power", }), defineChatCommand({ key: "approve", @@ -203,6 +214,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/approve", acceptsArgs: true, category: "management", + tier: "power", }), defineChatCommand({ key: "context", @@ -211,6 +223,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/context", acceptsArgs: true, category: "status", + tier: "standard", }), defineChatCommand({ key: "btw", @@ -219,6 +232,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/btw", acceptsArgs: true, category: "tools", + tier: "standard", }), defineChatCommand({ key: "export-session", @@ -227,6 +241,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAliases: ["/export-session", "/export"], acceptsArgs: true, category: "status", + tier: "essential", args: [ { name: "path", @@ -242,6 +257,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Control text-to-speech (TTS).", textAlias: "/tts", category: "media", + tier: "standard", args: [ { name: "action", @@ -285,6 +301,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show your sender id.", textAlias: "/whoami", category: "status", + tier: "power", }), defineChatCommand({ key: "session", @@ -292,6 +309,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Manage session-level settings (for example /session idle).", textAlias: "/session", category: "session", + tier: "power", args: [ { name: "action", @@ -314,6 +332,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "List, kill, log, spawn, or steer subagent runs for this session.", textAlias: "/subagents", category: "management", + tier: "standard", args: [ { name: "action", @@ -341,6 +360,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Manage ACP sessions and runtime options.", textAlias: "/acp", category: "management", + tier: "power", args: [ { name: "action", @@ -382,6 +402,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { "Bind this thread (Discord) or topic/conversation (Telegram) to a session target.", textAlias: "/focus", category: "management", + tier: "power", args: [ { name: "target", @@ -397,6 +418,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.", textAlias: "/unfocus", category: "management", + tier: "power", }), defineChatCommand({ key: "agents", @@ -404,6 +426,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "List thread-bound agents for this session.", textAlias: "/agents", category: "management", + tier: "standard", }), defineChatCommand({ key: "kill", @@ -411,6 +434,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Kill a running subagent (or all).", textAlias: "/kill", category: "management", + tier: "standard", args: [ { name: "target", @@ -426,6 +450,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Send guidance to a running subagent.", textAlias: "/steer", category: "management", + tier: "standard", args: [ { name: "target", @@ -446,6 +471,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show or set config values.", textAlias: "/config", category: "management", + tier: "power", args: [ { name: "action", @@ -474,6 +500,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show or set OpenClaw MCP servers.", textAlias: "/mcp", category: "management", + tier: "power", args: [ { name: "action", @@ -502,6 +529,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "List, show, enable, or disable plugins.", textAliases: ["/plugins", "/plugin"], category: "management", + tier: "power", args: [ { name: "action", @@ -524,6 +552,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Set runtime debug overrides.", textAlias: "/debug", category: "management", + tier: "power", args: [ { name: "action", @@ -552,6 +581,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Usage footer or cost summary.", textAlias: "/usage", category: "options", + tier: "standard", args: [ { name: "mode", @@ -568,6 +598,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Stop the current run.", textAlias: "/stop", category: "session", + tier: "essential", }), defineChatCommand({ key: "restart", @@ -575,6 +606,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Restart OpenClaw.", textAlias: "/restart", category: "tools", + tier: "power", }), defineChatCommand({ key: "activation", @@ -582,6 +614,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Set group activation mode.", textAlias: "/activation", category: "management", + tier: "power", args: [ { name: "mode", @@ -598,6 +631,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Set send policy.", textAlias: "/send", category: "management", + tier: "power", args: [ { name: "mode", @@ -615,6 +649,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/reset", acceptsArgs: true, category: "session", + tier: "essential", }), defineChatCommand({ key: "new", @@ -623,6 +658,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/new", acceptsArgs: true, category: "session", + tier: "essential", }), defineChatCommand({ key: "compact", @@ -630,6 +666,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Compact the session context.", textAlias: "/compact", category: "session", + tier: "essential", args: [ { name: "instructions", @@ -645,6 +682,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Set thinking level.", textAlias: "/think", category: "options", + tier: "essential", args: [ { name: "level", @@ -661,6 +699,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Toggle verbose mode.", textAlias: "/verbose", category: "options", + tier: "standard", args: [ { name: "mode", @@ -677,6 +716,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Toggle plugin trace lines.", textAlias: "/trace", category: "options", + tier: "power", args: [ { name: "mode", @@ -693,6 +733,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Toggle fast mode.", textAlias: "/fast", category: "options", + tier: "standard", args: [ { name: "mode", @@ -709,6 +750,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Toggle reasoning visibility.", textAlias: "/reasoning", category: "options", + tier: "standard", args: [ { name: "mode", @@ -725,6 +767,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Toggle elevated mode.", textAlias: "/elevated", category: "options", + tier: "power", args: [ { name: "mode", @@ -741,6 +784,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Set exec defaults for this session.", textAlias: "/exec", category: "options", + tier: "power", args: [ { name: "host", @@ -775,6 +819,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Show or set the model.", textAlias: "/model", category: "options", + tier: "essential", args: [ { name: "model", @@ -788,6 +833,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { nativeName: "models", description: "List model providers or provider models.", textAlias: "/models", + tier: "standard", argsParsing: "none", acceptsArgs: true, category: "options", @@ -798,6 +844,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { description: "Adjust queue settings.", textAlias: "/queue", category: "options", + tier: "power", args: [ { name: "mode", @@ -831,6 +878,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/bash", scope: "text", category: "tools", + tier: "power", args: [ { name: "command", diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index c3cab177665..8a5555cd268 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -50,6 +50,7 @@ export type { CommandDetection, CommandNormalizeOptions, CommandScope, + CommandTier, NativeCommandSpec, ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 662dce0ded4..c154af65493 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -5,6 +5,14 @@ export type { CommandArgValue, CommandArgValues, CommandArgs } from "./commands- export type CommandScope = "text" | "native" | "both"; +/** + * Controls progressive disclosure of commands in the UI. + * - "essential": Always visible (~10 core commands) + * - "standard": Shown on expand / "Show more" (~15 commands) + * - "power": Only surfaced via search or explicit filter (~15 commands) + */ +export type CommandTier = "essential" | "standard" | "power"; + export type CommandCategory = | "session" | "options" @@ -57,6 +65,8 @@ export type ChatCommandDefinition = { argsMenu?: CommandArgMenuSpec | "auto"; scope: CommandScope; category?: CommandCategory; + /** Progressive disclosure tier. Defaults to "standard" when omitted. */ + tier?: CommandTier; }; export type NativeCommandSpec = { diff --git a/src/config/merge-patch.ts b/src/config/merge-patch.ts index e0aa8caca01..8b76456bf50 100644 --- a/src/config/merge-patch.ts +++ b/src/config/merge-patch.ts @@ -1,4 +1,4 @@ -import { isPlainObject } from "../utils.js"; +import { isPlainObject } from "../infra/plain-object.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; type PlainObject = Record; diff --git a/ui/src/styles.css b/ui/src/styles.css index dac145d3a0b..23fc6a55c85 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -4,6 +4,8 @@ @import "./styles/components.css"; @import "./styles/chat.css"; @import "./styles/config.css"; +@import "./styles/config-quick.css"; +@import "./styles/cron-quick-create.css"; @import "./styles/usage.css"; @import "./styles/dreams.css"; @import "@create-markdown/preview/themes/system.css"; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 392f18ff245..b841361be33 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -766,6 +766,31 @@ flex-shrink: 0; } +.slash-menu-show-more { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + padding: 8px 10px; + margin-top: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--accent); + background: none; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.slash-menu-show-more:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--accent-hover); +} + .slash-menu-footer { display: flex; gap: 10px; diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css new file mode 100644 index 00000000000..ceab9619eae --- /dev/null +++ b/ui/src/styles/config-quick.css @@ -0,0 +1,586 @@ +/* ── Quick Settings ── */ + +.qs-container { + width: 100%; + max-width: none; + margin: 0; + padding: 32px 0 56px; +} + +.qs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.qs-header__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; + color: var(--text-strong); +} + +.qs-header__title svg { + width: 22px; + height: 22px; + opacity: 0.5; + color: currentColor; + stroke: currentColor; + fill: none; +} + +.qs-header__title svg * { + stroke: currentColor; + fill: none; +} + +/* ── Grid ── */ + +.qs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + align-items: stretch; + gap: 14px; +} + +/* ── Card ── */ + +.qs-card { + min-width: 0; + height: 100%; + background: var(--card); + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-lg); + overflow: hidden; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out); +} + +.qs-card:hover { + border-color: color-mix(in srgb, var(--border-strong) 70%, transparent); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); +} + +.qs-card--span-all { + grid-column: 1 / -1; +} + +.qs-card__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: color-mix(in srgb, var(--bg-elevated) 60%, var(--card) 40%); +} + +.qs-card__header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.qs-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex: 0 0 auto; + background: transparent; + color: inherit; +} + +.qs-card__icon svg { + width: 14px; + height: 14px; + color: inherit; + stroke: currentColor; + fill: none; +} + +.qs-card__icon svg * { + stroke: currentColor; + fill: none; +} + +.qs-card__title { + font-size: 0.8125rem; + font-weight: 650; + letter-spacing: -0.01em; + margin: 0; + color: var(--text-strong); +} + +.qs-card__body { + padding: 0; +} + +/* ── Row ── */ + +.qs-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 16px; + min-height: 38px; + gap: 10px; +} + +.qs-row + .qs-row { + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.qs-row__label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8125rem; + font-weight: 450; + color: var(--text); + white-space: nowrap; +} + +.qs-row__value { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8125rem; + color: var(--muted); +} + +.qs-row__value--action { + cursor: pointer; + background: none; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: + background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); + color: var(--text); + font: inherit; + font-size: 0.8125rem; +} + +.qs-row__value--action:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.qs-row__value--action code { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--accent); +} + +.qs-row__chevron svg { + width: 12px; + height: 12px; + opacity: 0.35; +} + +/* ── Segmented control ── */ + +.qs-segmented { + display: flex; + gap: 2px; + background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: var(--radius-md); + padding: 2px; +} + +.qs-segmented__btn { + font-size: 0.75rem; + font-weight: 550; + padding: 4px 12px; + border: none; + border-radius: calc(var(--radius-md) - 3px); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all var(--duration-normal) var(--ease-out); + white-space: nowrap; + position: relative; +} + +.qs-segmented__btn--compact { + padding: 5px 10px; +} + +.qs-segmented__btn:hover { + color: var(--text); +} + +.qs-segmented__btn--active { + background: var(--bg-elevated); + color: var(--text-strong); + box-shadow: var(--shadow-sm); +} + +/* ── Toggle switch ── */ + +.qs-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.qs-toggle input { + display: none; +} + +.qs-toggle__track { + position: relative; + width: 38px; + height: 22px; + background: color-mix(in srgb, var(--bg) 70%, var(--border) 30%); + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 11px; + transition: + background var(--duration-normal) var(--ease-out), + border-color var(--duration-normal) var(--ease-out); + flex-shrink: 0; +} + +.qs-toggle__track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: var(--muted); + border-radius: 50%; + transition: + transform var(--duration-normal) var(--ease-spring), + background var(--duration-normal) var(--ease-out); +} + +.qs-toggle input:checked + .qs-toggle__track { + background: var(--accent); + border-color: var(--accent); +} + +.qs-toggle input:checked + .qs-toggle__track::after { + transform: translateX(16px); + background: #fff; +} + +.qs-toggle__hint { + font-size: 0.75rem; + color: var(--muted); +} + +/* ── Badge ── */ + +.qs-badge { + display: inline-flex; + align-items: center; + font-size: 0.6875rem; + font-weight: 600; + padding: 3px 9px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--bg-elevated) 80%, var(--border) 20%); + color: var(--muted); + text-transform: capitalize; + letter-spacing: 0.01em; +} + +.qs-badge--ok { + background: var(--ok-subtle); + color: var(--ok); +} + +.qs-badge--warn { + background: var(--warn-subtle); + color: var(--warn); +} + +/* ── Status dot ── */ + +.qs-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; + transition: background var(--duration-normal) var(--ease-out); +} + +.qs-status-dot--ok { + background: var(--ok); + box-shadow: 0 0 6px var(--ok-subtle); +} + +/* ── Masked value ── */ + +.qs-masked { + font-family: var(--mono); + font-size: 0.75rem; + letter-spacing: 1.5px; + color: var(--muted); +} + +/* ── Link button ── */ + +.qs-link-btn { + font-size: 0.75rem; + font-weight: 600; + color: var(--accent); + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + transition: + opacity var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out); + white-space: nowrap; +} + +.qs-link-btn:hover { + opacity: 0.85; + background: var(--accent-subtle); +} + +/* ── Empty state ── */ + +.qs-empty { + padding: 14px 16px; + font-size: 0.8125rem; + color: var(--muted); +} + +/* ── Footer ── */ + +.qs-footer { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.qs-footer__row { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.75rem; + color: var(--muted); +} + +/* ── Config Accordion Nav (Advanced Settings) ── */ + +.config-accordion-nav { + margin-bottom: 20px; +} + +.config-accordion-nav__back { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + font-weight: 600; + color: var(--accent); + background: none; + border: none; + cursor: pointer; + padding: 8px 12px; + margin-bottom: 8px; + border-radius: var(--radius-md); + transition: + opacity var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out); +} + +.config-accordion-nav__back:hover { + opacity: 0.85; + background: var(--accent-subtle); +} + +.config-accordion-group { + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + margin-bottom: 4px; + overflow: hidden; + transition: border-color var(--duration-fast) var(--ease-out); +} + +.config-accordion-group:hover { + border-color: var(--border); +} + +.config-accordion-group__header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + font-size: 0.8125rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-strong); + background: color-mix(in srgb, var(--bg-elevated) 50%, var(--card) 50%); + border: none; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-out); +} + +.config-accordion-group__header:hover { + background: var(--bg-hover); +} + +.config-accordion-group__header--active { + background: var(--bg-hover); +} + +.config-accordion-group__icon { + display: flex; + align-items: center; + color: var(--accent); + opacity: 0.7; +} + +.config-accordion-group__icon svg { + width: 16px; + height: 16px; +} + +.config-accordion-group__chevron { + margin-left: auto; + opacity: 0.35; + transition: transform var(--duration-normal) var(--ease-out); +} + +.config-accordion-group__chevron--open { + transform: rotate(180deg); +} + +.config-accordion-group__items { + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + padding: 4px 0; + background: var(--card); +} + +.config-accordion-group__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 9px 16px 9px 40px; + font-size: 0.8125rem; + font-weight: 450; + color: var(--muted); + background: none; + border: none; + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.config-accordion-group__item:hover { + background: var(--bg-hover); + color: var(--text); +} + +.config-accordion-group__item--active { + color: var(--accent); + font-weight: 600; +} + +.config-accordion-group__item-icon { + display: flex; + align-items: center; + opacity: 0.45; +} + +.config-accordion-group__item-icon svg { + width: 14px; + height: 14px; +} + +/* ── Presets Grid ── */ + +.qs-presets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 8px; + padding: 12px 16px !important; +} + +.qs-preset { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: var(--card); + cursor: pointer; + transition: + border-color var(--duration-normal) var(--ease-out), + background var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out); + text-align: left; +} + +.qs-preset:hover { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%); + background: color-mix(in srgb, var(--accent-subtle) 40%, var(--card) 60%); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); +} + +.qs-preset--active { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%); + background: color-mix(in srgb, var(--accent-subtle) 60%, var(--card) 40%); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.qs-preset--active:hover { + background: color-mix(in srgb, var(--accent-subtle) 80%, var(--card) 20%); +} + +.qs-preset__icon { + font-size: 1.35rem; + line-height: 1; +} + +.qs-preset__label { + font-size: 0.8125rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-strong); +} + +.qs-preset__desc { + font-size: 0.6875rem; + line-height: 1.4; + color: var(--muted); +} + +@media (max-width: 480px) { + .qs-container { + padding: 20px 0 40px; + } + + .qs-presets-grid { + grid-template-columns: 1fr; + } + + .qs-header { + align-items: flex-start; + } + + .qs-header__title { + font-size: 1.15rem; + } +} diff --git a/ui/src/styles/cron-quick-create.css b/ui/src/styles/cron-quick-create.css new file mode 100644 index 00000000000..296bc4b302e --- /dev/null +++ b/ui/src/styles/cron-quick-create.css @@ -0,0 +1,378 @@ +/* ── Cron Quick Create Wizard ── */ + +.cqc-container { + max-width: 600px; + margin: 0 auto; + padding: 32px 20px 56px; +} + +.cqc-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; +} + +.cqc-header__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.2rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0; + color: var(--text-strong); +} + +.cqc-header__title svg { + width: 20px; + height: 20px; + color: var(--accent); +} + +.cqc-header__close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.cqc-header__close:hover { + background: var(--bg-hover); + border-color: var(--border-strong); + color: var(--text); +} + +.cqc-header__close svg { + width: 14px; + height: 14px; +} + +/* ── Step Indicator ── */ + +.cqc-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + margin-bottom: 36px; +} + +.cqc-step { + display: flex; + align-items: center; + gap: 8px; +} + +.cqc-step__dot { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + font-size: 0.6875rem; + font-weight: 700; + background: color-mix(in srgb, var(--bg-elevated) 80%, var(--border) 20%); + color: var(--muted); + flex-shrink: 0; + transition: + background var(--duration-slow) var(--ease-out), + color var(--duration-slow) var(--ease-out), + box-shadow var(--duration-slow) var(--ease-out); +} + +.cqc-step--active .cqc-step__dot { + background: var(--accent); + color: var(--primary-foreground); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.cqc-step--done .cqc-step__dot { + background: var(--ok-subtle); + color: var(--ok); +} + +.cqc-step__label { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + transition: color var(--duration-slow) var(--ease-out); +} + +.cqc-step--active .cqc-step__label { + color: var(--text-strong); +} + +.cqc-step--done .cqc-step__label { + color: var(--ok); +} + +.cqc-step__line { + width: 48px; + height: 2px; + background: color-mix(in srgb, var(--border) 60%, transparent); + margin: 0 10px; + border-radius: 1px; + transition: background var(--duration-slow) var(--ease-out); +} + +.cqc-step__line--done { + background: color-mix(in srgb, var(--ok) 40%, transparent); +} + +.cqc-step__line--active { + background: color-mix(in srgb, var(--accent) 50%, transparent); +} + +/* ── Body ── */ + +.cqc-body { + margin-bottom: 28px; +} + +.cqc-body__heading { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0 0 4px; + color: var(--text-strong); +} + +.cqc-body__hint { + font-size: 0.8125rem; + margin: 0 0 20px; + color: var(--muted); + line-height: 1.5; +} + +/* ── Form elements ── */ + +.cqc-textarea { + width: 100%; + min-height: 110px; + padding: 14px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: var(--card); + color: var(--text); + font: inherit; + font-size: 0.875rem; + line-height: 1.6; + resize: vertical; + box-sizing: border-box; + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} + +.cqc-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.cqc-textarea::placeholder { + color: var(--muted); + opacity: 0.7; +} + +.cqc-field { + margin-top: 16px; +} + +.cqc-field__label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + margin-bottom: 6px; + letter-spacing: 0.01em; +} + +.cqc-input { + width: 100%; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: var(--card); + color: var(--text); + font: inherit; + font-size: 0.8125rem; + box-sizing: border-box; + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} + +.cqc-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.cqc-input::placeholder { + color: var(--muted); + opacity: 0.7; +} + +/* ── Schedule Preset Grid ── */ + +.cqc-preset-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.cqc-preset-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 18px 10px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: var(--card); + cursor: pointer; + transition: + border-color var(--duration-normal) var(--ease-out), + background var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-fast) var(--ease-out); + text-align: center; +} + +.cqc-preset-card:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border) 65%); + background: color-mix(in srgb, var(--accent-subtle) 30%, var(--card) 70%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +} + +.cqc-preset-card--active { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%); + background: color-mix(in srgb, var(--accent-subtle) 50%, var(--card) 50%); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.cqc-preset-card--active:hover { + background: color-mix(in srgb, var(--accent-subtle) 70%, var(--card) 30%); +} + +.cqc-preset-card__icon { + font-size: 1.5rem; + line-height: 1; +} + +.cqc-preset-card__label { + font-size: 0.8125rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-strong); +} + +.cqc-preset-card__desc { + font-size: 0.6875rem; + color: var(--muted); + line-height: 1.3; +} + +/* ── Delivery Radio Cards ── */ + +.cqc-delivery-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cqc-radio-card { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: var(--card); + cursor: pointer; + transition: + border-color var(--duration-normal) var(--ease-out), + background var(--duration-normal) var(--ease-out); +} + +.cqc-radio-card:hover { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border) 70%); + background: color-mix(in srgb, var(--accent-subtle) 20%, var(--card) 80%); +} + +.cqc-radio-card--active { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border) 55%); + background: color-mix(in srgb, var(--accent-subtle) 35%, var(--card) 65%); +} + +.cqc-radio-card input[type="radio"] { + accent-color: var(--accent); + margin: 0; + width: 16px; + height: 16px; +} + +.cqc-radio-card__label { + font-size: 0.875rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-strong); +} + +.cqc-radio-card__desc { + font-size: 0.75rem; + margin-left: auto; + color: var(--muted); +} + +/* ── Actions ── */ + +.cqc-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 20px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.cqc-actions .btn.primary { + display: flex; + align-items: center; + gap: 4px; +} + +.cqc-actions .btn.primary svg { + width: 14px; + height: 14px; +} + +/* ── Responsive ── */ + +@media (max-width: 480px) { + .cqc-container { + padding: 20px 14px 40px; + } + + .cqc-preset-grid { + grid-template-columns: repeat(2, 1fr); + } + + .cqc-step__line { + width: 32px; + margin: 0 6px; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8ac9cb457f9..ea0c4ecdd2c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { applyMergePatch } from "../../../src/config/merge-patch.ts"; import { buildAgentMainSessionKey, parseAgentSessionKey, @@ -7,6 +8,7 @@ import { import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; import { refreshChatAvatar } from "./app-chat.ts"; +import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderChatControls, @@ -45,6 +47,7 @@ import { updateConfigFormValue, removeConfigFormValue, } from "./controllers/config.ts"; +import { cloneConfigObject, serializeConfigForm } from "./controllers/config/form-utils.ts"; import { loadCronJobsPage, loadCronRuns, @@ -129,7 +132,18 @@ import { } from "./views/agents-utils.ts"; import { renderChat } from "./views/chat.ts"; import { renderCommandPalette } from "./views/command-palette.ts"; +import { getPresetById, type ConfigPresetId } from "./views/config-presets.ts"; +import { + renderQuickSettings, + type QuickSettingsChannel, + type QuickSettingsApiKey, +} from "./views/config-quick.ts"; import { renderConfig, type ConfigProps } from "./views/config.ts"; +import { + renderCronQuickCreate, + createDefaultDraft, + draftToCronFormPatch, +} from "./views/cron-quick-create.ts"; import { renderDreaming } from "./views/dreaming.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; @@ -350,6 +364,8 @@ type ConfigTabOverrides = Pick< | "includeSections" | "excludeSections" | "includeVirtualSections" + | "settingsLayout" + | "onBackToQuick" > >; @@ -398,6 +414,239 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { return identity?.avatarUrl; } +// ── Quick Settings data extraction helpers ── + +const KNOWN_CHANNEL_IDS = [ + { id: "telegram", label: "Telegram" }, + { id: "discord", label: "Discord" }, + { id: "slack", label: "Slack" }, + { id: "whatsapp", label: "WhatsApp" }, + { id: "signal", label: "Signal" }, + { id: "imessage", label: "iMessage" }, +] as const; + +const KNOWN_PROVIDER_KEYS = [ + { provider: "anthropic", label: "Anthropic", envKey: "ANTHROPIC_API_KEY" }, + { provider: "openai", label: "OpenAI", envKey: "OPENAI_API_KEY" }, + { provider: "google", label: "Google", envKey: "GOOGLE_API_KEY" }, + { provider: "openrouter", label: "OpenRouter", envKey: "OPENROUTER_API_KEY" }, +] as const; + +function formatQuickSettingsLabel(id: string): string { + const trimmed = id.trim(); + if (!trimmed) { + return "Unknown"; + } + return trimmed + .split(/[-_]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function extractQuickSettingsChannels(state: AppViewState): QuickSettingsChannel[] { + const config = state.configForm ?? state.configSnapshot?.config; + if (!config || typeof config !== "object") { + return []; + } + const channelsConfig = + "channels" in config && config.channels && typeof config.channels === "object" + ? (config.channels as Record) + : {}; + const configuredIds = Object.keys(channelsConfig).filter((id) => id.trim().length > 0); + const channelIds = + configuredIds.length > 0 + ? configuredIds.toSorted((a, b) => a.localeCompare(b)) + : KNOWN_CHANNEL_IDS.map(({ id }) => id); + const knownLabels = new Map( + KNOWN_CHANNEL_IDS.map(({ id, label }) => [id, label]), + ); + const channels: QuickSettingsChannel[] = []; + for (const id of channelIds) { + const channelConfig = channelsConfig[id]; + const hasConfig = + channelConfig != null && + typeof channelConfig === "object" && + Object.keys(channelConfig).length > 0; + channels.push({ + id, + label: knownLabels.get(id) ?? formatQuickSettingsLabel(id), + connected: hasConfig, + detail: hasConfig ? "Configured" : undefined, + }); + } + return channels; +} + +function extractQuickSettingsApiKeys(state: AppViewState): QuickSettingsApiKey[] { + const config = state.configForm ?? state.configSnapshot?.config; + const env = config && typeof config === "object" ? config.env : null; + const envObj = env && typeof env === "object" ? (env as Record) : {}; + const envVars = + envObj.vars && typeof envObj.vars === "object" ? (envObj.vars as Record) : {}; + return KNOWN_PROVIDER_KEYS.map(({ provider, label, envKey }) => { + const value = typeof envVars[envKey] === "string" ? envVars[envKey] : envObj[envKey]; + const isSet = typeof value === "string" && value.trim().length > 0; + const masked = isSet ? `••••${value.slice(-4)}` : undefined; + return { provider, label, masked, isSet }; + }); +} + +function extractMcpServerCount(state: AppViewState): number { + const config = state.configForm ?? state.configSnapshot?.config; + if (!config || typeof config !== "object") { + return 0; + } + const mcp = config.mcp; + if (!mcp || typeof mcp !== "object") { + return 0; + } + const servers = + "servers" in mcp && mcp.servers && typeof mcp.servers === "object" + ? (mcp.servers as Record) + : {}; + return Object.keys(servers).length; +} + +function extractQuickSettingsSecurity(state: AppViewState): { + gatewayAuth: string; + execPolicy: string; + deviceAuth: boolean; +} { + const config = state.configForm ?? state.configSnapshot?.config; + if (!config || typeof config !== "object") { + return { gatewayAuth: "unknown", execPolicy: "unknown", deviceAuth: false }; + } + const cfg = config; + const gateway = + "gateway" in cfg && cfg.gateway && typeof cfg.gateway === "object" + ? (cfg.gateway as Record) + : null; + const auth = + gateway && "auth" in gateway && gateway.auth && typeof gateway.auth === "object" + ? (gateway.auth as Record) + : null; + let gatewayAuth = "unknown"; + if (auth) { + const mode = typeof auth.mode === "string" ? auth.mode.trim() : ""; + if (mode) { + gatewayAuth = mode; + } else if (auth.password) { + gatewayAuth = "password"; + } else if (auth.token) { + gatewayAuth = "token"; + } else if (auth.trustedProxy) { + gatewayAuth = "trusted-proxy"; + } else { + gatewayAuth = "none"; + } + } + const agents = cfg.agents; + let execPolicy = "allowlist"; + if (agents && typeof agents === "object") { + const defaults = (agents as Record).defaults; + if (defaults && typeof defaults === "object") { + const exec = (defaults as Record).exec; + if (exec && typeof exec === "object") { + const security = (exec as Record).security; + if (typeof security === "string") { + execPolicy = security; + } + } + } + } + let deviceAuth = true; + if (gateway) { + const controlUi = + "controlUi" in gateway && gateway.controlUi && typeof gateway.controlUi === "object" + ? (gateway.controlUi as Record) + : null; + if (controlUi?.dangerouslyDisableDeviceAuth === true) { + deviceAuth = false; + } + } + return { gatewayAuth, execPolicy, deviceAuth }; +} + +function resolveQuickSettingsSessionRow(state: AppViewState) { + return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); +} + +async function applyQuickSettingsPreset(state: AppViewState, presetId: ConfigPresetId) { + if (!state.client || !state.connected) { + return; + } + const preset = getPresetById(presetId); + if (!preset) { + return; + } + state.configApplying = true; + state.lastError = null; + try { + if (!state.configSnapshot?.hash) { + await loadConfig(state); + } + const baseHash = state.configSnapshot?.hash?.trim(); + if (!baseHash) { + throw new Error("Config base hash unavailable. Reload config and retry."); + } + const baseConfig = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); + const merged = applyMergePatch(baseConfig, preset.patch) as Record; + await state.client.request("config.patch", { raw: serializeConfigForm(merged), baseHash }); + await loadConfig(state); + } catch (err) { + state.lastError = `Failed to apply preset: ${String(err)}`; + } finally { + state.configApplying = false; + } +} + +function renderCronQuickCreateForTab( + state: AppViewState, + requestHostUpdate: (() => void) | undefined, +) { + return renderCronQuickCreate({ + open: state.cronQuickCreateOpen, + step: state.cronQuickCreateStep, + draft: state.cronQuickCreateDraft ?? createDefaultDraft(), + onDraftChange: (patch) => { + state.cronQuickCreateDraft = { + ...(state.cronQuickCreateDraft ?? createDefaultDraft()), + ...patch, + }; + requestHostUpdate?.(); + }, + onStepChange: (step) => { + state.cronQuickCreateStep = step; + requestHostUpdate?.(); + }, + onCreate: () => { + const draft = state.cronQuickCreateDraft ?? createDefaultDraft(); + const formPatch = draftToCronFormPatch(draft); + state.cronEditingJobId = null; + state.cronForm = { ...DEFAULT_CRON_FORM, ...formPatch } as typeof state.cronForm; + requestHostUpdate?.(); + void (async () => { + await addCronJob(state); + if (state.cronError || hasCronFormErrors(state.cronFieldErrors)) { + requestHostUpdate?.(); + return; + } + state.cronQuickCreateOpen = false; + state.cronQuickCreateStep = "what"; + state.cronQuickCreateDraft = null; + requestHostUpdate?.(); + })(); + }, + onCancel: () => { + state.cronQuickCreateOpen = false; + state.cronQuickCreateStep = "what"; + state.cronQuickCreateDraft = null; + requestHostUpdate?.(); + }, + }); +} + export function renderApp(state: AppViewState) { const updatableState = state as AppViewState & { requestUpdate?: () => void }; const requestHostUpdate = @@ -671,7 +920,106 @@ export function renderApp(state: AppViewState) { ); const renderConfigTabForActiveTab = () => { switch (state.tab) { - case "config": + case "config": { + // Quick Settings mode — opinionated card layout + if (state.configSettingsMode === "quick") { + const configObj = state.configForm ?? state.configSnapshot?.config ?? {}; + const agentsDefaults = ((configObj.agents as Record | undefined) + ?.defaults ?? {}) as Record; + const activeSession = resolveQuickSettingsSessionRow(state); + const currentModel = + typeof activeSession?.model === "string" + ? activeSession.model + : typeof agentsDefaults.model === "string" + ? agentsDefaults.model + : "default"; + const thinkingLevel = + typeof activeSession?.thinkingLevel === "string" + ? activeSession.thinkingLevel + : typeof agentsDefaults.thinkingLevel === "string" + ? agentsDefaults.thinkingLevel + : "off"; + const fastMode = + typeof activeSession?.fastMode === "boolean" + ? activeSession.fastMode + : agentsDefaults.fastMode === true; + return renderQuickSettings({ + currentModel, + thinkingLevel, + fastMode, + onModelChange: () => { + state.configSettingsMode = "advanced"; + state.tab = "aiAgents" as import("./navigation.ts").Tab; + state.aiAgentsActiveSection = "models"; + requestHostUpdate?.(); + }, + onThinkingChange: (level) => { + void patchSession(state, state.sessionKey, { thinkingLevel: level }).then(() => + requestHostUpdate?.(), + ); + }, + onFastModeToggle: () => { + void patchSession(state, state.sessionKey, { fastMode: !fastMode }).then(() => + requestHostUpdate?.(), + ); + }, + channels: extractQuickSettingsChannels(state), + onChannelConfigure: () => { + state.tab = "communications" as import("./navigation.ts").Tab; + state.communicationsActiveSection = "channels"; + requestHostUpdate?.(); + }, + apiKeys: extractQuickSettingsApiKeys(state), + onApiKeyChange: () => { + state.configSettingsMode = "advanced"; + state.configActiveSection = "env"; + requestHostUpdate?.(); + }, + automation: { + cronJobCount: state.cronJobs?.length ?? 0, + skillCount: state.skillsReport?.skills?.length ?? 0, + mcpServerCount: extractMcpServerCount(state), + }, + onManageCron: () => { + state.tab = "cron" as import("./navigation.ts").Tab; + requestHostUpdate?.(); + }, + onBrowseSkills: () => { + state.tab = "skills" as import("./navigation.ts").Tab; + requestHostUpdate?.(); + }, + onConfigureMcp: () => { + state.tab = "infrastructure" as import("./navigation.ts").Tab; + state.infrastructureActiveSection = "mcp"; + requestHostUpdate?.(); + }, + security: extractQuickSettingsSecurity(state), + onSecurityConfigure: () => { + state.configSettingsMode = "advanced"; + state.configActiveSection = "auth"; + requestHostUpdate?.(); + }, + theme: state.theme, + themeMode: state.themeMode, + borderRadius: state.settings.borderRadius, + setTheme: (theme, context) => state.setTheme(theme, context), + setThemeMode: (mode, context) => state.setThemeMode(mode, context), + setBorderRadius: (value) => state.setBorderRadius(value), + configObject: configObj, + onApplyPreset: (presetId) => { + void applyQuickSettingsPreset(state, presetId).then(() => requestHostUpdate?.()); + }, + onAdvancedSettings: () => { + state.configSettingsMode = "advanced"; + requestHostUpdate?.(); + }, + connected: state.connected, + gatewayUrl: state.settings.gatewayUrl, + assistantName: state.assistantName, + version: state.hello?.server?.version ?? "", + }); + } + // Advanced mode — full config form with accordion groups return renderConfigTab({ formMode: state.configFormMode, searchQuery: state.configSearchQuery, @@ -685,6 +1033,11 @@ export function renderApp(state: AppViewState) { }, onSubsectionChange: (section) => (state.configActiveSubsection = section), showModeToggle: true, + settingsLayout: "accordion", + onBackToQuick: () => { + state.configSettingsMode = "quick"; + requestHostUpdate?.(); + }, excludeSections: [ ...COMMUNICATION_SECTION_KEYS, ...AUTOMATION_SECTION_KEYS, @@ -694,6 +1047,7 @@ export function renderApp(state: AppViewState) { "wizard", ], }); + } case "communications": return renderConfigTab({ formMode: state.communicationsFormMode, @@ -1293,6 +1647,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${renderUsageTab(state)} + ${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing} ${state.tab === "cron" ? lazyRender(lazyCron, (m) => m.renderCron({ @@ -1349,6 +1704,12 @@ export function renderApp(state: AppViewState) { onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onRun: (job, mode) => runCronJob(state, job, mode ?? "force"), onRemove: (job) => removeCronJob(state, job), + onQuickCreate: () => { + state.cronQuickCreateOpen = true; + state.cronQuickCreateStep = "what"; + state.cronQuickCreateDraft = createDefaultDraft(); + requestHostUpdate?.(); + }, onLoadRuns: async (jobId) => { updateCronRunsFilter(state, { cronRunsScope: "job" }); await loadCronRuns(state, jobId); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index c6f75a01542..9be78c44072 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -149,6 +149,7 @@ export type AppViewState = { wikiMemoryPalaceError: string | null; wikiMemoryPalace: import("./controllers/dreaming.js").WikiMemoryPalace | null; configFormMode: "form" | "raw"; + configSettingsMode: "quick" | "advanced"; configSearchQuery: string; configActiveSection: string | null; configActiveSubsection: string | null; @@ -274,6 +275,9 @@ export type AppViewState = { } & Pick< CronState, | "cronLoading" + | "cronQuickCreateOpen" + | "cronQuickCreateStep" + | "cronQuickCreateDraft" | "cronJobsLoadingMore" | "cronJobs" | "cronJobsTotal" diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index db46425a8fd..7e910075462 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -251,6 +251,7 @@ export class OpenClawApp extends LitElement { @state() wikiMemoryPalaceError: string | null = null; @state() wikiMemoryPalace: WikiMemoryPalace | null = null; @state() configFormDirty = false; + @state() configSettingsMode: "quick" | "advanced" = "quick"; @state() configFormMode: "form" | "raw" = "form"; @state() configSearchQuery = ""; @state() configActiveSection: string | null = null; @@ -396,6 +397,11 @@ export class OpenClawApp extends LitElement { usageQueryDebounceTimer: number | null = null; @state() cronLoading = false; + @state() cronQuickCreateOpen = false; + @state() cronQuickCreateStep: import("./views/cron-quick-create.ts").CronQuickCreateStep = "what"; + @state() cronQuickCreateDraft: + | import("./views/cron-quick-create.ts").CronQuickCreateDraft + | null = null; @state() cronJobsLoadingMore = false; @state() cronJobs: CronJob[] = []; @state() cronJobsTotal = 0; diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index 7ab50f28de7..29fd51666d2 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -6,6 +6,8 @@ import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; +export type SlashCommandTier = "essential" | "standard" | "power"; + export type SlashCommandDef = { key: string; name: string; @@ -20,6 +22,8 @@ export type SlashCommandDef = { argOptions?: string[]; /** Keyboard shortcut hint shown in the menu (display only). */ shortcut?: string; + /** Progressive disclosure tier. Defaults to "standard" when omitted. */ + tier?: SlashCommandTier; }; type LocalArgChoice = string | { value: string; label: string }; @@ -35,6 +39,7 @@ type CommandLike = { choices?: LocalArgChoice[]; }>; category?: string; + tier?: string; }; const REMOTE_SLASH_IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u; @@ -101,6 +106,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [ icon: "trash", category: "session", executeLocal: true, + tier: "standard", }, { key: "redirect", @@ -110,6 +116,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [ icon: "refresh", category: "agents", executeLocal: true, + tier: "power", }, ]; @@ -213,6 +220,14 @@ function mapIcon(command: CommandLike): IconName | undefined { return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal"; } +function mapTier(command: CommandLike): SlashCommandTier { + const raw = command.tier; + if (raw === "essential" || raw === "standard" || raw === "power") { + return raw; + } + return "standard"; +} + function toSlashCommand( command: CommandLike, source: "local" | "remote" = "local", @@ -231,6 +246,7 @@ function toSlashCommand( category: mapCategory(command), executeLocal: source === "local" && LOCAL_COMMANDS.has(command.key), argOptions: getArgOptions(command), + tier: source === "local" ? mapTier(command) : "standard", }; } @@ -309,6 +325,7 @@ function buildLocalSlashCommands(): SlashCommandDef[] { choices: Array.isArray(arg.choices) ? arg.choices : undefined, })), category: command.category, + tier: command.tier, })) .map((command) => toSlashCommand(command, "local")) .filter((command): command is SlashCommandDef => command !== null); @@ -454,9 +471,19 @@ export const CATEGORY_LABELS: Record = { tools: "Tools", }; -export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { +const TIER_ORDER: Record = { + essential: 0, + standard: 1, + power: 2, +}; + +export function getSlashCommandCompletions( + filter: string, + options?: { showAll?: boolean }, +): SlashCommandDef[] { const lower = normalizeLowercaseStringOrEmpty(filter); - const commands = lower + const showAll = options?.showAll ?? false; + let commands = lower ? SLASH_COMMANDS.filter( (cmd) => cmd.name.startsWith(lower) || @@ -464,7 +491,19 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { normalizeLowercaseStringOrEmpty(cmd.description).includes(lower), ) : SLASH_COMMANDS; + + // When no filter text and not explicitly showing all, hide "power" tier commands + if (!lower && !showAll) { + commands = commands.filter((cmd) => (cmd.tier ?? "standard") !== "power"); + } + return commands.toSorted((a, b) => { + // Sort by tier first (essential → standard → power) + const aTier = TIER_ORDER[a.tier ?? "standard"] ?? 1; + const bTier = TIER_ORDER[b.tier ?? "standard"] ?? 1; + if (aTier !== bTier) { + return aTier - bTier; + } const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); if (ai !== bi) { @@ -481,6 +520,11 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { }); } +/** Count of commands hidden by tier filtering (for "Show N more" UI). */ +export function getHiddenCommandCount(): number { + return SLASH_COMMANDS.filter((cmd) => (cmd.tier ?? "standard") === "power").length; +} + export type ParsedSlashCommand = { command: SlashCommandDef; args: string; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 11a32981635..f4e16c0d2c6 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -19,6 +19,9 @@ function createState(overrides: Partial = {}): CronState { client: null, connected: true, cronLoading: false, + cronQuickCreateOpen: false, + cronQuickCreateStep: "what", + cronQuickCreateDraft: null, cronJobsLoadingMore: false, cronJobs: [], cronJobsTotal: 0, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index d8e5d013c59..cdf28d9192d 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -47,6 +47,9 @@ export type CronState = { client: GatewayBrowserClient | null; connected: boolean; cronLoading: boolean; + cronQuickCreateOpen: boolean; + cronQuickCreateStep: import("../views/cron-quick-create.ts").CronQuickCreateStep; + cronQuickCreateDraft: import("../views/cron-quick-create.ts").CronQuickCreateDraft | null; cronJobsLoadingMore: boolean; cronJobs: CronJob[]; cronJobsTotal: number; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 4a4c46d7b3a..e8ea6cc40ac 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -29,6 +29,7 @@ import type { ChatSideResult } from "../chat/side-result.ts"; import { CATEGORY_LABELS, SLASH_COMMANDS, + getHiddenCommandCount, getSlashCommandCompletions, type SlashCommandCategory, type SlashCommandDef, @@ -292,6 +293,7 @@ interface ChatEphemeralState { slashMenuMode: "command" | "args"; slashMenuCommand: SlashCommandDef | null; slashMenuArgItems: string[]; + slashMenuExpanded: boolean; searchOpen: boolean; searchQuery: string; pinnedExpanded: boolean; @@ -307,6 +309,7 @@ function createChatEphemeralState(): ChatEphemeralState { slashMenuMode: "command", slashMenuCommand: null, slashMenuArgItems: [], + slashMenuExpanded: false, searchOpen: false, searchQuery: "", pinnedExpanded: false, @@ -725,6 +728,7 @@ function resetSlashMenuState(): void { vs.slashMenuCommand = null; vs.slashMenuArgItems = []; vs.slashMenuItems = []; + vs.slashMenuExpanded = false; } function updateSlashMenu(value: string, requestUpdate: () => void): void { @@ -758,7 +762,7 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void { // Command mode: /partial-command const match = value.match(/^\/(\S*)$/); if (match) { - const items = getSlashCommandCompletions(match[1]); + const items = getSlashCommandCompletions(match[1], { showAll: vs.slashMenuExpanded }); vs.slashMenuItems = items; vs.slashMenuOpen = items.length > 0; vs.slashMenuIndex = 0; @@ -1107,9 +1111,24 @@ function renderSlashMenu( `); } + const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount(); + return html`
${sections} + ${hiddenCount > 0 + ? html`` + : nothing} diff --git a/ui/src/ui/views/config-presets.ts b/ui/src/ui/views/config-presets.ts new file mode 100644 index 00000000000..94af39d1cd7 --- /dev/null +++ b/ui/src/ui/views/config-presets.ts @@ -0,0 +1,109 @@ +/** + * Config presets — opinionated configuration bundles that set multiple + * settings at once. Applied via config.patch. + */ + +export type ConfigPresetId = "personal" | "codeAgent" | "teamBot" | "minimal"; + +export type ConfigPreset = { + id: ConfigPresetId; + label: string; + description: string; + icon: string; + patch: Record; +}; + +export const CONFIG_PRESETS: ConfigPreset[] = [ + { + id: "personal", + label: "Personal Assistant", + description: "Balanced context and cost. Best for daily use.", + icon: "✨", + patch: { + agents: { + defaults: { + bootstrapMaxChars: 20_000, + bootstrapTotalMaxChars: 150_000, + contextInjection: "always", + }, + }, + }, + }, + { + id: "codeAgent", + label: "Code Agent", + description: "Higher context for coding tasks. More tokens per turn.", + icon: "🛠️", + patch: { + agents: { + defaults: { + bootstrapMaxChars: 50_000, + bootstrapTotalMaxChars: 300_000, + contextInjection: "always", + }, + }, + }, + }, + { + id: "teamBot", + label: "Team Bot", + description: "Multi-channel, group-aware. Leaner per-turn context.", + icon: "👥", + patch: { + agents: { + defaults: { + bootstrapMaxChars: 10_000, + bootstrapTotalMaxChars: 80_000, + contextInjection: "continuation-skip", + }, + }, + }, + }, + { + id: "minimal", + label: "Minimal", + description: "Lowest cost per turn. Fast and lean.", + icon: "⚡", + patch: { + agents: { + defaults: { + bootstrapMaxChars: 5_000, + bootstrapTotalMaxChars: 30_000, + contextInjection: "continuation-skip", + }, + }, + }, + }, +]; + +export function getPresetById(id: ConfigPresetId): ConfigPreset | undefined { + return CONFIG_PRESETS.find((p) => p.id === id); +} + +/** + * Detect which preset (if any) matches the current config values. + */ +export function detectActivePreset(config: Record): ConfigPresetId | null { + const agents = config.agents as Record | undefined; + const defaults = agents?.defaults as Record | undefined; + if (!defaults) { + return "personal"; // treat unset as default + } + const maxChars = defaults.bootstrapMaxChars; + const totalMax = defaults.bootstrapTotalMaxChars; + for (const preset of CONFIG_PRESETS) { + const presetDefaults = (preset.patch.agents as Record)?.defaults as + | Record + | undefined; + if (!presetDefaults) { + continue; + } + if ( + maxChars === presetDefaults.bootstrapMaxChars && + totalMax === presetDefaults.bootstrapTotalMaxChars + ) { + return preset.id; + } + } + return null; +} diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts new file mode 100644 index 00000000000..d0969b54b49 --- /dev/null +++ b/ui/src/ui/views/config-quick.ts @@ -0,0 +1,442 @@ +/** + * Quick Settings view — opinionated card layout for the most common settings. + * Replaces the raw schema-driven form as the default settings experience. + * + * Each card answers a "what do I want to do?" question with status + actions. + */ + +import { html, nothing, type TemplateResult } from "lit"; +import { icons } from "../icons.ts"; +import type { BorderRadiusStop } from "../storage.ts"; +import type { ThemeTransitionContext } from "../theme-transition.ts"; +import type { ThemeMode, ThemeName } from "../theme.ts"; +import { CONFIG_PRESETS, detectActivePreset, type ConfigPresetId } from "./config-presets.ts"; + +// ── Types ── + +export type QuickSettingsChannel = { + id: string; + label: string; + connected: boolean; + detail?: string; +}; + +export type QuickSettingsApiKey = { + provider: string; + label: string; + masked?: string; + isSet: boolean; +}; + +export type QuickSettingsAutomation = { + cronJobCount: number; + skillCount: number; + mcpServerCount: number; +}; + +export type QuickSettingsSecurity = { + gatewayAuth: string; + execPolicy: string; + deviceAuth: boolean; +}; + +export type QuickSettingsProps = { + // Model & Thinking + currentModel: string; + thinkingLevel: string; + fastMode: boolean; + onModelChange?: () => void; + onThinkingChange?: (level: string) => void; + onFastModeToggle?: () => void; + + // Channels + channels: QuickSettingsChannel[]; + onChannelConfigure?: (channelId: string) => void; + + // API Keys + apiKeys: QuickSettingsApiKey[]; + onApiKeyChange?: (provider: string) => void; + + // Automations + automation: QuickSettingsAutomation; + onManageCron?: () => void; + onBrowseSkills?: () => void; + onConfigureMcp?: () => void; + + // Security + security: QuickSettingsSecurity; + onSecurityConfigure?: () => void; + + // Appearance + theme: ThemeName; + themeMode: ThemeMode; + borderRadius: number; + setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; + setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; + setBorderRadius: (value: number) => void; + + // Presets + configObject?: Record; + onApplyPreset?: (presetId: ConfigPresetId) => void; + + // Navigation + onAdvancedSettings?: () => void; + + // Connection + connected: boolean; + gatewayUrl: string; + assistantName: string; + version: string; +}; + +// ── Theme options ── + +type ThemeOption = { id: ThemeName; label: string }; +const THEME_OPTIONS: ThemeOption[] = [ + { id: "claw", label: "Claw" }, + { id: "knot", label: "Knot" }, + { id: "dash", label: "Dash" }, +]; + +const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [ + { value: 0, label: "None" }, + { value: 25, label: "Slight" }, + { value: 50, label: "Default" }, + { value: 75, label: "Round" }, + { value: 100, label: "Full" }, +]; + +const THINKING_LEVELS = ["off", "low", "medium", "high"]; + +// ── Card renderers ── + +function renderCardHeader(icon: TemplateResult, title: string, action?: TemplateResult) { + return html` +
+
+ ${icon} +

${title}

+
+ ${action ? action : nothing} +
+ `; +} + +function renderModelCard(props: QuickSettingsProps) { + return html` +
+ ${renderCardHeader(icons.brain, "Model & Thinking")} +
+
+ Model + +
+
+ Thinking +
+ ${THINKING_LEVELS.map( + (level) => html` + + `, + )} +
+
+
+ Fast mode + +
+
+
+ `; +} + +function renderChannelsCard(props: QuickSettingsProps) { + const connectedCount = props.channels.filter((c) => c.connected).length; + const badge = + connectedCount > 0 + ? html`${connectedCount} connected` + : undefined; + + return html` +
+ ${renderCardHeader(icons.send, "Channels", badge)} +
+ ${props.channels.length === 0 + ? html`
No channels configured
` + : props.channels.map( + (ch) => html` +
+ + + ${ch.label} + + + ${ch.connected + ? html`${ch.detail ?? "Connected"}` + : html``} + +
+ `, + )} +
+
+ `; +} + +function renderApiKeysCard(props: QuickSettingsProps) { + return html` +
+ ${renderCardHeader(icons.plug, "API Keys")} +
+ ${props.apiKeys.length === 0 + ? html`
No API keys configured
` + : props.apiKeys.map( + (key) => html` +
+ ${key.label} + + ${key.isSet + ? html` + ${key.masked ?? "••••••••"} + + ` + : html``} + +
+ `, + )} +
+
+ `; +} + +function renderAutomationsCard(props: QuickSettingsProps) { + const { cronJobCount, skillCount, mcpServerCount } = props.automation; + + return html` +
+ ${renderCardHeader(icons.zap, "Automations")} +
+
+ + ${cronJobCount} scheduled task${cronJobCount !== 1 ? "s" : ""} + + +
+
+ + ${skillCount} skill${skillCount !== 1 ? "s" : ""} installed + + +
+
+ + ${mcpServerCount} MCP server${mcpServerCount !== 1 ? "s" : ""} + + +
+
+
+ `; +} + +function renderSecurityCard(props: QuickSettingsProps) { + const { gatewayAuth, execPolicy, deviceAuth } = props.security; + + return html` +
+ ${renderCardHeader( + icons.eye, + "Security", + html``, + )} +
+
+ Gateway auth + + ${gatewayAuth} + +
+
+ Exec policy + ${execPolicy} +
+
+ Device auth + + ${deviceAuth ? "Enabled" : "Disabled"} + +
+
+
+ `; +} + +function renderAppearanceCard(props: QuickSettingsProps) { + return html` +
+ ${renderCardHeader(icons.spark, "Appearance")} +
+
+ Theme +
+ ${THEME_OPTIONS.map( + (opt) => html` + + `, + )} +
+
+
+ Mode +
+ ${(["light", "dark", "system"] as ThemeMode[]).map( + (mode) => html` + + `, + )} +
+
+
+ Roundness +
+ ${BORDER_RADIUS_STOPS.map( + (stop) => html` + + `, + )} +
+
+
+
+ `; +} + +function renderPresetsCard(props: QuickSettingsProps) { + const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal"; + + return html` +
+ ${renderCardHeader(icons.zap, "Profile")} +
+ ${CONFIG_PRESETS.map( + (preset) => html` + + `, + )} +
+
+ `; +} + +function renderConnectionFooter(props: QuickSettingsProps) { + return html` + + `; +} + +// ── Main render ── + +export function renderQuickSettings(props: QuickSettingsProps) { + return html` +
+
+

${icons.settings} Settings

+ +
+ +
+ ${renderModelCard(props)} ${renderChannelsCard(props)} ${renderApiKeysCard(props)} + ${renderAutomationsCard(props)} ${renderSecurityCard(props)} ${renderAppearanceCard(props)} + ${renderPresetsCard(props)} +
+ + ${renderConnectionFooter(props)} +
+ `; +} diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 1245bc449b2..26a89ae1f3f 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -70,6 +70,10 @@ export type ConfigProps = { includeSections?: string[]; excludeSections?: string[]; includeVirtualSections?: boolean; + /** Layout mode: "tabs" (default flat scroll) or "accordion" (grouped collapsible). */ + settingsLayout?: "tabs" | "accordion"; + /** Callback to navigate back to Quick Settings. Shown in accordion mode. */ + onBackToQuick?: () => void; onRequestUpdate?: () => void; }; @@ -760,6 +764,93 @@ export function renderConfig(props: ConfigProps) { ), ]; + const settingsLayout = props.settingsLayout ?? "tabs"; + const allCategories = [...visibleCategories, ...(otherCategory ? [otherCategory] : [])]; + + function renderAccordionNav() { + return html` +
+ ${props.onBackToQuick + ? html` + + ` + : nothing} + ${allCategories.map( + (cat) => html` +
+ + ${cat.sections.some((s) => s.key === props.activeSection) + ? html` +
+ ${cat.sections.map( + (s) => html` + + `, + )} +
+ ` + : nothing} +
+ `, + )} +
+ `; + } + // Compute diff for showing changes (works for both form and raw modes) const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw; @@ -857,67 +948,72 @@ export function renderConfig(props: ConfigProps) {
-
- ${formMode === "form" - ? html` - - ` - : nothing} + + + + + props.onSearchChange((e.target as HTMLInputElement).value)} + /> + ${props.searchQuery + ? html` + + ` + : nothing} +
+ + ` + : nothing} -
- ${topTabs.map( - (tab) => html` - - `, - )} -
- - + ${topTabs.map( + (tab) => html` + + `, + )} + + + `} ${validity === "invalid" && !cvs.validityDismissed ? html`
diff --git a/ui/src/ui/views/cron-quick-create.node.test.ts b/ui/src/ui/views/cron-quick-create.node.test.ts new file mode 100644 index 00000000000..ca6dc4f787a --- /dev/null +++ b/ui/src/ui/views/cron-quick-create.node.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { draftToCronFormPatch, type CronQuickCreateDraft } from "./cron-quick-create.ts"; + +function createDraft(overrides: Partial = {}): CronQuickCreateDraft { + return { + prompt: "Check inbox", + name: "Inbox check", + schedulePreset: "every-morning", + deliveryPreset: "notify", + ...overrides, + }; +} + +describe("cron quick create", () => { + it("sets a valid scheduleAt for one-time presets", () => { + const patch = draftToCronFormPatch(createDraft({ schedulePreset: "once" })); + + expect(patch.scheduleKind).toBe("at"); + expect(patch.deleteAfterRun).toBe(true); + expect(typeof patch.scheduleAt).toBe("string"); + expect(Date.parse(String(patch.scheduleAt))).not.toBeNaN(); + }); + + it("clears deleteAfterRun and scheduleAt for recurring presets", () => { + const patch = draftToCronFormPatch(createDraft({ schedulePreset: "weekly" })); + + expect(patch.scheduleKind).toBe("cron"); + expect(patch.cronExpr).toBe("0 9 * * 1"); + expect(patch.deleteAfterRun).toBe(false); + expect(patch.scheduleAt).toBe(""); + }); + + it("keeps notify preset announce-capable by targeting an isolated session", () => { + const patch = draftToCronFormPatch(createDraft({ deliveryPreset: "notify" })); + + expect(patch.sessionTarget).toBe("isolated"); + expect(patch.deliveryMode).toBe("announce"); + expect(patch.wakeMode).toBe("now"); + }); +}); diff --git a/ui/src/ui/views/cron-quick-create.ts b/ui/src/ui/views/cron-quick-create.ts new file mode 100644 index 00000000000..de1532c6050 --- /dev/null +++ b/ui/src/ui/views/cron-quick-create.ts @@ -0,0 +1,319 @@ +/** + * Simplified automation creation flow — Routines-style guided wizard. + * + * "What should it do?" → "When should it run?" → "How should it deliver?" + * + * Maps to the existing CronFormState fields under the hood. + */ + +import { html, nothing } from "lit"; +import { icons } from "../icons.ts"; +import type { CronFormState } from "../ui-types.ts"; + +// ── Types ── + +export type CronQuickCreateProps = { + open: boolean; + step: CronQuickCreateStep; + draft: CronQuickCreateDraft; + onDraftChange: (patch: Partial) => void; + onStepChange: (step: CronQuickCreateStep) => void; + onCreate: () => void; + onCancel: () => void; +}; + +export type CronQuickCreateStep = "what" | "when" | "how"; + +export type CronQuickCreateDraft = { + prompt: string; + name: string; + schedulePreset: SchedulePresetId | "custom"; + deliveryPreset: DeliveryPresetId; +}; + +type SchedulePresetId = + | "every-morning" + | "every-evening" + | "hourly" + | "weekdays" + | "weekly" + | "once"; + +type DeliveryPresetId = "notify" | "silent" | "isolated"; + +// ── Presets ── + +type SchedulePreset = { + id: SchedulePresetId; + label: string; + icon: string; + description: string; +}; + +const SCHEDULE_PRESETS: SchedulePreset[] = [ + { id: "every-morning", label: "Every morning", icon: "🌅", description: "Daily at 8:00 AM" }, + { id: "every-evening", label: "Every evening", icon: "🌙", description: "Daily at 6:00 PM" }, + { id: "hourly", label: "Hourly", icon: "🔄", description: "Every hour" }, + { id: "weekdays", label: "Weekdays", icon: "📅", description: "Mon–Fri at 9:00 AM" }, + { id: "weekly", label: "Weekly", icon: "📆", description: "Every Monday at 9:00 AM" }, + { id: "once", label: "Run once", icon: "⚡", description: "One-time, delete after run" }, +]; + +type DeliveryPreset = { + id: DeliveryPresetId; + label: string; + description: string; +}; + +const DELIVERY_PRESETS: DeliveryPreset[] = [ + { id: "notify", label: "Notify me", description: "Deliver results to chat" }, + { id: "silent", label: "Silent", description: "Run without notification" }, + { id: "isolated", label: "Independent session", description: "Run in its own session" }, +]; + +// ── Default draft ── + +export function createDefaultDraft(): CronQuickCreateDraft { + return { + prompt: "", + name: "", + schedulePreset: "every-morning", + deliveryPreset: "notify", + }; +} + +function buildDefaultScheduleAt(now = new Date()): string { + const next = new Date(now); + next.setHours(next.getHours() + 1, 0, 0, 0); + const year = next.getFullYear(); + const month = String(next.getMonth() + 1).padStart(2, "0"); + const day = String(next.getDate()).padStart(2, "0"); + const hour = String(next.getHours()).padStart(2, "0"); + const minute = String(next.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +// ── Convert draft to CronFormState patch ── + +export function draftToCronFormPatch(draft: CronQuickCreateDraft): Partial { + const patch: Partial = { + name: draft.name || "Automation", + payloadKind: "agentTurn", + deleteAfterRun: false, + scheduleAt: "", + payloadText: draft.prompt, + enabled: true, + }; + + // Schedule + switch (draft.schedulePreset) { + case "every-morning": + patch.scheduleKind = "cron"; + patch.cronExpr = "0 8 * * *"; + break; + case "every-evening": + patch.scheduleKind = "cron"; + patch.cronExpr = "0 18 * * *"; + break; + case "hourly": + patch.scheduleKind = "every"; + patch.everyAmount = "1"; + patch.everyUnit = "hours"; + break; + case "weekdays": + patch.scheduleKind = "cron"; + patch.cronExpr = "0 9 * * 1-5"; + break; + case "weekly": + patch.scheduleKind = "cron"; + patch.cronExpr = "0 9 * * 1"; + break; + case "once": + patch.scheduleKind = "at"; + patch.scheduleAt = buildDefaultScheduleAt(); + patch.deleteAfterRun = true; + break; + default: + break; + } + + // Delivery + switch (draft.deliveryPreset) { + case "notify": + patch.sessionTarget = "isolated"; + patch.deliveryMode = "announce"; + patch.wakeMode = "now"; + break; + case "silent": + patch.sessionTarget = "main"; + patch.deliveryMode = "none"; + patch.wakeMode = "now"; + break; + case "isolated": + patch.sessionTarget = "isolated"; + patch.deliveryMode = "none"; + patch.wakeMode = "now"; + break; + } + + return patch; +} + +// ── Step indicators ── + +const STEPS: CronQuickCreateStep[] = ["what", "when", "how"]; +const STEP_LABELS: Record = { + what: "What", + when: "When", + how: "How", +}; + +function renderStepIndicator(current: CronQuickCreateStep) { + const currentIdx = STEPS.indexOf(current); + return html` +
+ ${STEPS.map((step, idx) => { + const state = idx < currentIdx ? "done" : idx === currentIdx ? "active" : "pending"; + return html` +
+ ${state === "done" ? "✓" : idx + 1} + ${STEP_LABELS[step]} +
+ ${idx < STEPS.length - 1 + ? html`
` + : nothing} + `; + })} +
+ `; +} + +// ── Step renderers ── + +function renderWhatStep(props: CronQuickCreateProps) { + return html` +
+

What should it do?

+

+ Describe the task in natural language. The agent will run this prompt each time. +

+ +
+ + + props.onDraftChange({ name: (e.target as HTMLInputElement).value })} + /> +
+
+
+ + +
+ `; +} + +function renderWhenStep(props: CronQuickCreateProps) { + return html` +
+

When should it run?

+

Pick a schedule. You can fine-tune it later.

+
+ ${SCHEDULE_PRESETS.map( + (preset) => html` + + `, + )} +
+
+
+ + +
+ `; +} + +function renderHowStep(props: CronQuickCreateProps) { + return html` +
+

How should it work?

+

Choose how results are delivered.

+
+ ${DELIVERY_PRESETS.map( + (preset) => html` + + `, + )} +
+
+
+ + +
+ `; +} + +// ── Main render ── + +export function renderCronQuickCreate(props: CronQuickCreateProps) { + if (!props.open) { + return nothing; + } + + return html` +
+
+

${icons.zap} New Automation

+ +
+ + ${renderStepIndicator(props.step)} + ${props.step === "what" + ? renderWhatStep(props) + : props.step === "when" + ? renderWhenStep(props) + : renderHowStep(props)} +
+ `; +} diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index baa97add8d6..5af1785859a 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -71,6 +71,8 @@ export type CronProps = { onToggle: (job: CronJob, enabled: boolean) => void; onRun: (job: CronJob, mode?: "force" | "due") => void; onRemove: (job: CronJob) => void; + /** Open the simplified creation wizard. */ + onQuickCreate?: () => void; onLoadRuns: (jobId: string) => void; onLoadMoreJobs: () => void; onJobsFiltersChange: (patch: { @@ -418,6 +420,9 @@ export function renderCron(props: CronProps) {
+ ${props.onQuickCreate + ? html` ` + : nothing}