mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
feat(ui): overhaul settings and slash command UX (#67819) thanks @BunsDev
Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -50,6 +50,7 @@ export type {
|
||||
CommandDetection,
|
||||
CommandNormalizeOptions,
|
||||
CommandScope,
|
||||
CommandTier,
|
||||
NativeCommandSpec,
|
||||
ShouldHandleTextCommandsParams,
|
||||
} from "./commands-registry.types.js";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
586
ui/src/styles/config-quick.css
Normal file
586
ui/src/styles/config-quick.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
378
ui/src/styles/cron-quick-create.css
Normal file
378
ui/src/styles/cron-quick-create.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>)
|
||||
: {};
|
||||
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<string, string>(
|
||||
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<string, unknown>) : {};
|
||||
const envVars =
|
||||
envObj.vars && typeof envObj.vars === "object" ? (envObj.vars as Record<string, unknown>) : {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: null;
|
||||
const auth =
|
||||
gateway && "auth" in gateway && gateway.auth && typeof gateway.auth === "object"
|
||||
? (gateway.auth as Record<string, unknown>)
|
||||
: 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<string, unknown>).defaults;
|
||||
if (defaults && typeof defaults === "object") {
|
||||
const exec = (defaults as Record<string, unknown>).exec;
|
||||
if (exec && typeof exec === "object") {
|
||||
const security = (exec as Record<string, unknown>).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<string, unknown>)
|
||||
: 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<string, unknown>;
|
||||
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<string, unknown> | undefined)
|
||||
?.defaults ?? {}) as Record<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SlashCommandCategory, string> = {
|
||||
tools: "Tools",
|
||||
};
|
||||
|
||||
export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
|
||||
const TIER_ORDER: Record<SlashCommandTier, number> = {
|
||||
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;
|
||||
|
||||
@@ -19,6 +19,9 @@ function createState(overrides: Partial<CronState> = {}): CronState {
|
||||
client: null,
|
||||
connected: true,
|
||||
cronLoading: false,
|
||||
cronQuickCreateOpen: false,
|
||||
cronQuickCreateStep: "what",
|
||||
cronQuickCreateDraft: null,
|
||||
cronJobsLoadingMore: false,
|
||||
cronJobs: [],
|
||||
cronJobsTotal: 0,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
<div class="slash-menu" role="listbox" aria-label="Slash commands">
|
||||
${sections}
|
||||
${hiddenCount > 0
|
||||
? html`<button
|
||||
class="slash-menu-show-more"
|
||||
@click=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
vs.slashMenuExpanded = true;
|
||||
updateSlashMenu(props.draft, requestUpdate);
|
||||
}}
|
||||
>
|
||||
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""}
|
||||
</button>`
|
||||
: nothing}
|
||||
<div class="slash-menu-footer">
|
||||
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
|
||||
</div>
|
||||
|
||||
109
ui/src/ui/views/config-presets.ts
Normal file
109
ui/src/ui/views/config-presets.ts
Normal file
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown>): ConfigPresetId | null {
|
||||
const agents = config.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | 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<string, unknown>)?.defaults as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!presetDefaults) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
maxChars === presetDefaults.bootstrapMaxChars &&
|
||||
totalMax === presetDefaults.bootstrapTotalMaxChars
|
||||
) {
|
||||
return preset.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
442
ui/src/ui/views/config-quick.ts
Normal file
442
ui/src/ui/views/config-quick.ts
Normal file
@@ -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<string, unknown>;
|
||||
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`
|
||||
<div class="qs-card__header">
|
||||
<div class="qs-card__header-left">
|
||||
<span class="qs-card__icon">${icon}</span>
|
||||
<h3 class="qs-card__title">${title}</h3>
|
||||
</div>
|
||||
${action ? action : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderModelCard(props: QuickSettingsProps) {
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.brain, "Model & Thinking")}
|
||||
<div class="qs-card__body">
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Model</span>
|
||||
<button class="qs-row__value qs-row__value--action" @click=${props.onModelChange}>
|
||||
<code>${props.currentModel || "default"}</code>
|
||||
<span class="qs-row__chevron">${icons.chevronRight}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Thinking</span>
|
||||
<div class="qs-segmented">
|
||||
${THINKING_LEVELS.map(
|
||||
(level) => html`
|
||||
<button
|
||||
class="qs-segmented__btn ${level === props.thinkingLevel
|
||||
? "qs-segmented__btn--active"
|
||||
: ""}"
|
||||
@click=${() => props.onThinkingChange?.(level)}
|
||||
>
|
||||
${level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Fast mode</span>
|
||||
<label class="qs-toggle">
|
||||
<input type="checkbox" .checked=${props.fastMode} @change=${props.onFastModeToggle} />
|
||||
<span class="qs-toggle__track"></span>
|
||||
<span class="qs-toggle__hint muted"
|
||||
>${props.fastMode ? "On — cheaper, less capable" : "Off"}</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderChannelsCard(props: QuickSettingsProps) {
|
||||
const connectedCount = props.channels.filter((c) => c.connected).length;
|
||||
const badge =
|
||||
connectedCount > 0
|
||||
? html`<span class="qs-badge qs-badge--ok">${connectedCount} connected</span>`
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.send, "Channels", badge)}
|
||||
<div class="qs-card__body">
|
||||
${props.channels.length === 0
|
||||
? html`<div class="qs-empty muted">No channels configured</div>`
|
||||
: props.channels.map(
|
||||
(ch) => html`
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">
|
||||
<span class="qs-status-dot ${ch.connected ? "qs-status-dot--ok" : ""}"></span>
|
||||
${ch.label}
|
||||
</span>
|
||||
<span class="qs-row__value">
|
||||
${ch.connected
|
||||
? html`<span class="muted">${ch.detail ?? "Connected"}</span>`
|
||||
: html`<button
|
||||
class="qs-link-btn"
|
||||
@click=${() => props.onChannelConfigure?.(ch.id)}
|
||||
>
|
||||
Connect →
|
||||
</button>`}
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderApiKeysCard(props: QuickSettingsProps) {
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.plug, "API Keys")}
|
||||
<div class="qs-card__body">
|
||||
${props.apiKeys.length === 0
|
||||
? html`<div class="qs-empty muted">No API keys configured</div>`
|
||||
: props.apiKeys.map(
|
||||
(key) => html`
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">${key.label}</span>
|
||||
<span class="qs-row__value">
|
||||
${key.isSet
|
||||
? html`
|
||||
<code class="qs-masked">${key.masked ?? "••••••••"}</code>
|
||||
<button
|
||||
class="qs-link-btn"
|
||||
@click=${() => props.onApiKeyChange?.(key.provider)}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
`
|
||||
: html`<button
|
||||
class="qs-link-btn"
|
||||
@click=${() => props.onApiKeyChange?.(key.provider)}
|
||||
>
|
||||
Add →
|
||||
</button>`}
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAutomationsCard(props: QuickSettingsProps) {
|
||||
const { cronJobCount, skillCount, mcpServerCount } = props.automation;
|
||||
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.zap, "Automations")}
|
||||
<div class="qs-card__body">
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">
|
||||
${cronJobCount} scheduled task${cronJobCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button class="qs-link-btn" @click=${props.onManageCron}>Manage →</button>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">
|
||||
${skillCount} skill${skillCount !== 1 ? "s" : ""} installed
|
||||
</span>
|
||||
<button class="qs-link-btn" @click=${props.onBrowseSkills}>Browse →</button>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">
|
||||
${mcpServerCount} MCP server${mcpServerCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button class="qs-link-btn" @click=${props.onConfigureMcp}>Configure →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSecurityCard(props: QuickSettingsProps) {
|
||||
const { gatewayAuth, execPolicy, deviceAuth } = props.security;
|
||||
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(
|
||||
icons.eye,
|
||||
"Security",
|
||||
html`<button class="qs-link-btn" @click=${props.onSecurityConfigure}>Configure →</button>`,
|
||||
)}
|
||||
<div class="qs-card__body">
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Gateway auth</span>
|
||||
<span class="qs-row__value">
|
||||
<span class="qs-badge ${gatewayAuth !== "none" ? "qs-badge--ok" : "qs-badge--warn"}"
|
||||
>${gatewayAuth}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Exec policy</span>
|
||||
<span class="qs-row__value"><span class="qs-badge">${execPolicy}</span></span>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Device auth</span>
|
||||
<span class="qs-row__value">
|
||||
<span class="qs-badge ${deviceAuth ? "qs-badge--ok" : "qs-badge--warn"}"
|
||||
>${deviceAuth ? "Enabled" : "Disabled"}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAppearanceCard(props: QuickSettingsProps) {
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
${renderCardHeader(icons.spark, "Appearance")}
|
||||
<div class="qs-card__body">
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Theme</span>
|
||||
<div class="qs-segmented">
|
||||
${THEME_OPTIONS.map(
|
||||
(opt) => html`
|
||||
<button
|
||||
class="qs-segmented__btn ${opt.id === props.theme
|
||||
? "qs-segmented__btn--active"
|
||||
: ""}"
|
||||
@click=${(e: Event) => {
|
||||
if (opt.id !== props.theme) {
|
||||
props.setTheme(opt.id, {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
${opt.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Mode</span>
|
||||
<div class="qs-segmented">
|
||||
${(["light", "dark", "system"] as ThemeMode[]).map(
|
||||
(mode) => html`
|
||||
<button
|
||||
class="qs-segmented__btn ${mode === props.themeMode
|
||||
? "qs-segmented__btn--active"
|
||||
: ""}"
|
||||
@click=${(e: Event) => {
|
||||
if (mode !== props.themeMode) {
|
||||
props.setThemeMode(mode, {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
${mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span class="qs-row__label">Roundness</span>
|
||||
<div class="qs-segmented">
|
||||
${BORDER_RADIUS_STOPS.map(
|
||||
(stop) => html`
|
||||
<button
|
||||
class="qs-segmented__btn qs-segmented__btn--compact ${stop.value ===
|
||||
props.borderRadius
|
||||
? "qs-segmented__btn--active"
|
||||
: ""}"
|
||||
@click=${() => props.setBorderRadius(stop.value)}
|
||||
>
|
||||
${stop.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPresetsCard(props: QuickSettingsProps) {
|
||||
const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal";
|
||||
|
||||
return html`
|
||||
<div class="qs-card qs-card--span-all">
|
||||
${renderCardHeader(icons.zap, "Profile")}
|
||||
<div class="qs-card__body qs-presets-grid">
|
||||
${CONFIG_PRESETS.map(
|
||||
(preset) => html`
|
||||
<button
|
||||
class="qs-preset ${preset.id === activePreset ? "qs-preset--active" : ""}"
|
||||
@click=${() => props.onApplyPreset?.(preset.id)}
|
||||
>
|
||||
<span class="qs-preset__icon">${preset.icon}</span>
|
||||
<span class="qs-preset__label">${preset.label}</span>
|
||||
<span class="qs-preset__desc muted">${preset.description}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConnectionFooter(props: QuickSettingsProps) {
|
||||
return html`
|
||||
<div class="qs-footer">
|
||||
<div class="qs-footer__row">
|
||||
<span class="qs-status-dot ${props.connected ? "qs-status-dot--ok" : ""}"></span>
|
||||
<span class="muted">${props.connected ? "Connected" : "Offline"}</span>
|
||||
${props.assistantName ? html`<span class="muted">· ${props.assistantName}</span>` : nothing}
|
||||
${props.version ? html`<span class="muted">· v${props.version}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
|
||||
export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
return html`
|
||||
<div class="qs-container">
|
||||
<div class="qs-header">
|
||||
<h2 class="qs-header__title">${icons.settings} Settings</h2>
|
||||
<button class="btn btn--sm" @click=${props.onAdvancedSettings}>
|
||||
Advanced ${icons.chevronRight}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="qs-grid">
|
||||
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderApiKeysCard(props)}
|
||||
${renderAutomationsCard(props)} ${renderSecurityCard(props)} ${renderAppearanceCard(props)}
|
||||
${renderPresetsCard(props)}
|
||||
</div>
|
||||
|
||||
${renderConnectionFooter(props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="config-accordion-nav">
|
||||
${props.onBackToQuick
|
||||
? html`
|
||||
<button class="config-accordion-nav__back" @click=${props.onBackToQuick}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
Quick Settings
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${allCategories.map(
|
||||
(cat) => html`
|
||||
<div class="config-accordion-group">
|
||||
<button
|
||||
class="config-accordion-group__header ${props.activeSection != null &&
|
||||
cat.sections.some((s) => s.key === props.activeSection)
|
||||
? "config-accordion-group__header--active"
|
||||
: ""}"
|
||||
@click=${() => {
|
||||
const firstKey = cat.sections[0]?.key ?? null;
|
||||
const isCurrentlyInGroup = cat.sections.some(
|
||||
(s) => s.key === props.activeSection,
|
||||
);
|
||||
props.onSectionChange(isCurrentlyInGroup ? null : firstKey);
|
||||
}}
|
||||
>
|
||||
<span class="config-accordion-group__icon">
|
||||
${getSectionIcon(cat.sections[0]?.key ?? "default")}
|
||||
</span>
|
||||
<span>${cat.label}</span>
|
||||
<svg
|
||||
class="config-accordion-group__chevron ${cat.sections.some(
|
||||
(s) => s.key === props.activeSection,
|
||||
)
|
||||
? "config-accordion-group__chevron--open"
|
||||
: ""}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
${cat.sections.some((s) => s.key === props.activeSection)
|
||||
? html`
|
||||
<div class="config-accordion-group__items">
|
||||
${cat.sections.map(
|
||||
(s) => html`
|
||||
<button
|
||||
class="config-accordion-group__item ${props.activeSection === s.key
|
||||
? "config-accordion-group__item--active"
|
||||
: ""}"
|
||||
@click=${() => props.onSectionChange(s.key)}
|
||||
>
|
||||
<span class="config-accordion-group__item-icon">
|
||||
${getSectionIcon(s.key)}
|
||||
</span>
|
||||
${s.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-top-tabs">
|
||||
${formMode === "form"
|
||||
? html`
|
||||
<div class="config-search config-search--top">
|
||||
<div class="config-search__input-row">
|
||||
<svg
|
||||
class="config-search__icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="config-search__input"
|
||||
placeholder="Search settings..."
|
||||
aria-label="Search settings"
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) =>
|
||||
props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${props.searchQuery
|
||||
? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
aria-label="Clear search"
|
||||
@click=${() => props.onSearchChange("")}
|
||||
${settingsLayout === "accordion"
|
||||
? renderAccordionNav()
|
||||
: html`
|
||||
<div class="config-top-tabs">
|
||||
${formMode === "form"
|
||||
? html`
|
||||
<div class="config-search config-search--top">
|
||||
<div class="config-search__input-row">
|
||||
<svg
|
||||
class="config-search__icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="config-search__input"
|
||||
placeholder="Search settings..."
|
||||
aria-label="Search settings"
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) =>
|
||||
props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
${props.searchQuery
|
||||
? html`
|
||||
<button
|
||||
class="config-search__clear"
|
||||
aria-label="Clear search"
|
||||
@click=${() => props.onSearchChange("")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div
|
||||
class="config-top-tabs__scroller"
|
||||
role="tablist"
|
||||
aria-label="${t("common.settingsSections")}"
|
||||
>
|
||||
${topTabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
class="config-top-tabs__tab ${props.activeSection === tab.key ? "active" : ""}"
|
||||
role="tab"
|
||||
aria-selected=${props.activeSection === tab.key}
|
||||
@click=${() => props.onSectionChange(tab.key)}
|
||||
title=${tab.label}
|
||||
<div
|
||||
class="config-top-tabs__scroller"
|
||||
role="tablist"
|
||||
aria-label="${t("common.settingsSections")}"
|
||||
>
|
||||
${tab.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${topTabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
class="config-top-tabs__tab ${props.activeSection === tab.key
|
||||
? "active"
|
||||
: ""}"
|
||||
role="tab"
|
||||
aria-selected=${props.activeSection === tab.key}
|
||||
@click=${() => props.onSectionChange(tab.key)}
|
||||
title=${tab.label}
|
||||
>
|
||||
${tab.label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
${validity === "invalid" && !cvs.validityDismissed
|
||||
? html`
|
||||
<div class="config-validity-warning">
|
||||
|
||||
40
ui/src/ui/views/cron-quick-create.node.test.ts
Normal file
40
ui/src/ui/views/cron-quick-create.node.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftToCronFormPatch, type CronQuickCreateDraft } from "./cron-quick-create.ts";
|
||||
|
||||
function createDraft(overrides: Partial<CronQuickCreateDraft> = {}): 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");
|
||||
});
|
||||
});
|
||||
319
ui/src/ui/views/cron-quick-create.ts
Normal file
319
ui/src/ui/views/cron-quick-create.ts
Normal file
@@ -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<CronQuickCreateDraft>) => 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<CronFormState> {
|
||||
const patch: Partial<CronFormState> = {
|
||||
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<CronQuickCreateStep, string> = {
|
||||
what: "What",
|
||||
when: "When",
|
||||
how: "How",
|
||||
};
|
||||
|
||||
function renderStepIndicator(current: CronQuickCreateStep) {
|
||||
const currentIdx = STEPS.indexOf(current);
|
||||
return html`
|
||||
<div class="cqc-steps">
|
||||
${STEPS.map((step, idx) => {
|
||||
const state = idx < currentIdx ? "done" : idx === currentIdx ? "active" : "pending";
|
||||
return html`
|
||||
<div class="cqc-step cqc-step--${state}">
|
||||
<span class="cqc-step__dot">${state === "done" ? "✓" : idx + 1}</span>
|
||||
<span class="cqc-step__label">${STEP_LABELS[step]}</span>
|
||||
</div>
|
||||
${idx < STEPS.length - 1
|
||||
? html`<div class="cqc-step__line cqc-step__line--${state}"></div>`
|
||||
: nothing}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Step renderers ──
|
||||
|
||||
function renderWhatStep(props: CronQuickCreateProps) {
|
||||
return html`
|
||||
<div class="cqc-body">
|
||||
<h3 class="cqc-body__heading">What should it do?</h3>
|
||||
<p class="cqc-body__hint muted">
|
||||
Describe the task in natural language. The agent will run this prompt each time.
|
||||
</p>
|
||||
<textarea
|
||||
class="cqc-textarea"
|
||||
placeholder="e.g., Check my inbox for urgent emails and summarize them..."
|
||||
rows="4"
|
||||
.value=${props.draft.prompt}
|
||||
@input=${(e: Event) =>
|
||||
props.onDraftChange({ prompt: (e.target as HTMLTextAreaElement).value })}
|
||||
></textarea>
|
||||
<div class="cqc-field">
|
||||
<label class="cqc-field__label">Name (optional)</label>
|
||||
<input
|
||||
class="cqc-input"
|
||||
type="text"
|
||||
placeholder="e.g., Morning inbox check"
|
||||
.value=${props.draft.name}
|
||||
@input=${(e: Event) =>
|
||||
props.onDraftChange({ name: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cqc-actions">
|
||||
<button class="btn" @click=${props.onCancel}>Cancel</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${!props.draft.prompt.trim()}
|
||||
@click=${() => props.onStepChange("when")}
|
||||
>
|
||||
Next ${icons.chevronRight}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWhenStep(props: CronQuickCreateProps) {
|
||||
return html`
|
||||
<div class="cqc-body">
|
||||
<h3 class="cqc-body__heading">When should it run?</h3>
|
||||
<p class="cqc-body__hint muted">Pick a schedule. You can fine-tune it later.</p>
|
||||
<div class="cqc-preset-grid">
|
||||
${SCHEDULE_PRESETS.map(
|
||||
(preset) => html`
|
||||
<button
|
||||
class="cqc-preset-card ${props.draft.schedulePreset === preset.id
|
||||
? "cqc-preset-card--active"
|
||||
: ""}"
|
||||
@click=${() => props.onDraftChange({ schedulePreset: preset.id })}
|
||||
>
|
||||
<span class="cqc-preset-card__icon">${preset.icon}</span>
|
||||
<span class="cqc-preset-card__label">${preset.label}</span>
|
||||
<span class="cqc-preset-card__desc muted">${preset.description}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cqc-actions">
|
||||
<button class="btn" @click=${() => props.onStepChange("what")}>Back</button>
|
||||
<button class="btn primary" @click=${() => props.onStepChange("how")}>
|
||||
Next ${icons.chevronRight}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHowStep(props: CronQuickCreateProps) {
|
||||
return html`
|
||||
<div class="cqc-body">
|
||||
<h3 class="cqc-body__heading">How should it work?</h3>
|
||||
<p class="cqc-body__hint muted">Choose how results are delivered.</p>
|
||||
<div class="cqc-delivery-options">
|
||||
${DELIVERY_PRESETS.map(
|
||||
(preset) => html`
|
||||
<label
|
||||
class="cqc-radio-card ${props.draft.deliveryPreset === preset.id
|
||||
? "cqc-radio-card--active"
|
||||
: ""}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="delivery"
|
||||
.checked=${props.draft.deliveryPreset === preset.id}
|
||||
@change=${() => props.onDraftChange({ deliveryPreset: preset.id })}
|
||||
/>
|
||||
<span class="cqc-radio-card__label">${preset.label}</span>
|
||||
<span class="cqc-radio-card__desc muted">${preset.description}</span>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cqc-actions">
|
||||
<button class="btn" @click=${() => props.onStepChange("when")}>Back</button>
|
||||
<button class="btn primary" @click=${props.onCreate}>Create ${icons.check}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
|
||||
export function renderCronQuickCreate(props: CronQuickCreateProps) {
|
||||
if (!props.open) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="cqc-container">
|
||||
<div class="cqc-header">
|
||||
<h2 class="cqc-header__title">${icons.zap} New Automation</h2>
|
||||
<button class="cqc-header__close" @click=${props.onCancel}>${icons.x}</button>
|
||||
</div>
|
||||
|
||||
${renderStepIndicator(props.step)}
|
||||
${props.step === "what"
|
||||
? renderWhatStep(props)
|
||||
: props.step === "when"
|
||||
? renderWhenStep(props)
|
||||
: renderHowStep(props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cron-summary-strip__actions">
|
||||
${props.onQuickCreate
|
||||
? html` <button class="btn btn--primary" @click=${props.onQuickCreate}>+ New</button> `
|
||||
: nothing}
|
||||
<button
|
||||
class=${props.loading ? "btn cron-refresh-btn--loading" : "btn"}
|
||||
?disabled=${props.loading}
|
||||
|
||||
Reference in New Issue
Block a user