chore(ui): remove unused imports and variables

Clean up all unused imports, variables, and parameters across
the UI codebase to comply with the new noUnusedLocals and
noUnusedParameters TypeScript compiler options.
This commit is contained in:
Cyrus Goh 2026-01-25 13:38:05 -08:00
parent 13661abbd8
commit 1e8681497c
No known key found for this signature in database
GPG Key ID: D547D2AF3FD0FD5A
83 changed files with 3204 additions and 3013 deletions

View File

@ -1,189 +1,61 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap");
:root {
/* Background - Warmer dark with depth */
--bg: #12141a;
--bg-accent: #14161d;
--bg-elevated: #1a1d25;
--bg-hover: #262a35;
--bg-muted: #262a35;
/* Card / Surface - More contrast between levels */
--card: #181b22;
--card-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05);
--popover: #181b22;
--popover-foreground: #f4f4f5;
/* Panel */
--panel: #12141a;
--panel-strong: #1a1d25;
--panel-hover: #262a35;
--chrome: rgba(18, 20, 26, 0.95);
--chrome-strong: rgba(18, 20, 26, 0.98);
/* Text - Slightly warmer */
--text: #e4e4e7;
--text-strong: #fafafa;
--chat-text: #e4e4e7;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
/* Border - Subtle but defined */
--border: #27272a;
--border-strong: #3f3f46;
--border-hover: #52525b;
--input: #27272a;
--ring: #ff5c5c;
/* Accent - Punchy signature red */
--accent: #ff5c5c;
--accent-hover: #ff7070;
--accent-muted: #ff5c5c;
--accent-subtle: rgba(255, 92, 92, 0.15);
--accent-foreground: #fafafa;
--accent-glow: rgba(255, 92, 92, 0.25);
--primary: #ff5c5c;
--primary-foreground: #ffffff;
/* Secondary - Teal accent for variety */
--secondary: #1e2028;
--secondary-foreground: #f4f4f5;
--accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic - More saturated */
--ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: rgba(34, 197, 94, 0.12);
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--warn: #f59e0b;
--warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6;
/* Focus - With glow */
--focus: rgba(255, 92, 92, 0.25);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
/* Grid */
--bg: #0a0f14;
--bg-accent: #111826;
--bg-grad-1: #162031;
--bg-grad-2: #1f2a22;
--bg-overlay: rgba(255, 255, 255, 0.05);
--bg-glow: rgba(245, 159, 74, 0.12);
--panel: rgba(14, 20, 30, 0.88);
--panel-strong: rgba(18, 26, 38, 0.96);
--chrome: rgba(9, 14, 20, 0.72);
--chrome-strong: rgba(9, 14, 20, 0.86);
--text: rgba(244, 246, 251, 0.96);
--chat-text: rgba(231, 237, 244, 0.92);
--muted: rgba(156, 169, 189, 0.72);
--border: rgba(255, 255, 255, 0.09);
--border-strong: rgba(255, 255, 255, 0.16);
--accent: #f59f4a;
--accent-2: #34c7b7;
--ok: #2bd97f;
--warn: #f2c94c;
--danger: #ff6b6b;
--focus: rgba(245, 159, 74, 0.35);
--grid-line: rgba(255, 255, 255, 0.04);
/* Theme transition */
--theme-switch-x: 50%;
--theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-glow: 0 0 30px var(--accent-glow);
/* Radii - Slightly larger for friendlier feel */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--radius: 8px;
/* Transitions - Snappy but smooth */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 350ms;
--mono:
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--font-body: "Work Sans", system-ui, sans-serif;
--font-display: "Unbounded", "Times New Roman", serif;
color-scheme: dark;
}
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] {
--bg: #fafafa;
--bg-accent: #f5f5f5;
--bg-elevated: #ffffff;
--bg-hover: #f0f0f0;
--bg-muted: #f0f0f0;
--bg-content: #f5f5f5;
--card: #ffffff;
--card-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.03);
--popover: #ffffff;
--popover-foreground: #18181b;
--panel: #fafafa;
--panel-strong: #f5f5f5;
--panel-hover: #ebebeb;
--chrome: rgba(250, 250, 250, 0.95);
--chrome-strong: rgba(250, 250, 250, 0.98);
--text: #3f3f46;
--text-strong: #18181b;
--chat-text: #3f3f46;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--border-hover: #a1a1aa;
--input: #e4e4e7;
--accent: #dc2626;
--accent-hover: #ef4444;
--accent-muted: #dc2626;
--accent-subtle: rgba(220, 38, 38, 0.12);
--accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15);
--primary: #dc2626;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #3f3f46;
--accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12);
--ok: #16a34a;
--ok-muted: rgba(22, 163, 74, 0.75);
--ok-subtle: rgba(22, 163, 74, 0.1);
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--warn: #d97706;
--warn-muted: rgba(217, 119, 6, 0.75);
--warn-subtle: rgba(217, 119, 6, 0.1);
--danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.1);
--info: #2563eb;
--focus: rgba(220, 38, 38, 0.2);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
--grid-line: rgba(0, 0, 0, 0.05);
/* Light shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-glow: 0 0 24px var(--accent-glow);
--bg: #f5f1ea;
--bg-accent: #ffffff;
--bg-grad-1: #f1e6d6;
--bg-grad-2: #e5eef4;
--bg-overlay: rgba(28, 32, 46, 0.05);
--bg-glow: rgba(52, 199, 183, 0.14);
--panel: rgba(255, 255, 255, 0.9);
--panel-strong: rgba(255, 255, 255, 0.97);
--chrome: rgba(255, 255, 255, 0.75);
--chrome-strong: rgba(255, 255, 255, 0.88);
--text: rgba(27, 36, 50, 0.98);
--chat-text: rgba(36, 48, 66, 0.9);
--muted: rgba(80, 94, 114, 0.7);
--border: rgba(18, 24, 40, 0.12);
--border-strong: rgba(18, 24, 40, 0.2);
--accent: #e28a3f;
--accent-2: #1ba99d;
--ok: #1aa86c;
--warn: #b3771c;
--danger: #d44848;
--focus: rgba(226, 138, 63, 0.35);
--grid-line: rgba(18, 24, 40, 0.06);
color-scheme: light;
}
@ -198,19 +70,32 @@ body {
body {
margin: 0;
font: 400 14px/1.55 var(--font-body);
letter-spacing: -0.02em;
background: var(--bg);
font: 15px/1.5 var(--font-body);
background:
radial-gradient(1200px 900px at 15% -10%, var(--bg-grad-1) 0%, transparent 55%) fixed,
radial-gradient(900px 700px at 80% 10%, var(--bg-grad-2) 0%, transparent 60%) fixed,
linear-gradient(160deg, var(--bg) 0%, var(--bg-accent) 100%) fixed;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Theme transition */
body::before {
content: "";
position: fixed;
inset: 0;
background:
linear-gradient(140deg, var(--bg-overlay) 0%, rgba(255, 255, 255, 0) 40%),
radial-gradient(620px 420px at 75% 75%, var(--bg-glow), transparent 60%);
pointer-events: none;
z-index: 0;
}
/* Grid overlay removed for cleaner look */
@keyframes theme-circle-transition {
0% {
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
100% {
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
@ -229,7 +114,7 @@ html.theme-transition::view-transition-old(theme) {
html.theme-transition::view-transition-new(theme) {
mix-blend-mode: normal;
z-index: 2;
animation: theme-circle-transition 0.4s var(--ease-out) forwards;
animation: theme-circle-transition 0.45s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
@ -247,12 +132,7 @@ clawdbot-app {
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: inherit;
}
button,
@ -263,35 +143,10 @@ select {
color: inherit;
}
::selection {
background: var(--accent-subtle);
color: var(--text-strong);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-strong);
}
/* Animations - Polished with spring feel */
@keyframes rise {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(6px);
}
to {
opacity: 1;
@ -299,26 +154,6 @@ select {
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dashboard-enter {
from {
opacity: 0;
@ -329,44 +164,3 @@ select {
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
}
50% {
box-shadow: 0 0 20px var(--accent-glow);
}
}
/* Stagger animation delays for grouped elements */
.stagger-1 { animation-delay: 0ms; }
.stagger-2 { animation-delay: 50ms; }
.stagger-3 { animation-delay: 100ms; }
.stagger-4 { animation-delay: 150ms; }
.stagger-5 { animation-delay: 200ms; }
.stagger-6 { animation-delay: 250ms; }
/* Focus visible styles */
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}

View File

@ -105,7 +105,9 @@ img.chat-avatar {
border-radius: var(--radius-lg);
padding: 10px 14px;
box-shadow: none;
transition: background 150ms ease-out, border-color 150ms ease-out;
transition:
background 150ms ease-out,
border-color 150ms ease-out;
max-width: 100%;
word-wrap: break-word;
}
@ -128,7 +130,9 @@ img.chat-avatar {
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, background 120ms ease-out;
transition:
opacity 120ms ease-out,
background 120ms ease-out;
}
.chat-copy-btn__icon {
@ -243,7 +247,8 @@ img.chat-avatar {
}
@keyframes pulsing-border {
0%, 100% {
0%,
100% {
border-color: var(--border);
}
50% {

View File

@ -77,7 +77,10 @@
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out;
transition:
background 150ms ease-out,
color 150ms ease-out,
border-color 150ms ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

View File

@ -4,17 +4,17 @@
border-radius: 8px;
padding: 12px;
margin-top: 8px;
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition: border-color 150ms ease-out, background 150ms ease-out;
transition:
border-color 150ms ease-out,
background 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */
max-height: 120px;
overflow: hidden;
}
.chat-tool-card:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
border-color: var(--accent);
background: rgba(0, 0, 0, 0.06);
}
/* First tool card in a group - no top margin */
@ -52,63 +52,33 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
width: 18px;
height: 18px;
font-size: 14px;
line-height: 1;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
vertical-align: middle;
flex-shrink: 0;
}
.chat-tool-card__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* "View >" action link */
.chat-tool-card__action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--accent);
opacity: 0.8;
transition: opacity 150ms ease-out;
}
.chat-tool-card__action svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card--clickable:hover .chat-tool-card__action {
opacity: 1;
}
/* Status indicator for completed/empty results */
.chat-tool-card__status {
display: inline-flex;
align-items: center;
font-size: 14px;
color: var(--ok);
}
.chat-tool-card__status svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__status-text {
font-size: 11px;
margin-top: 4px;
@ -126,18 +96,18 @@
color: var(--muted);
margin-top: 8px;
padding: 8px 10px;
background: var(--secondary);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.08);
border-radius: 6px;
white-space: pre-wrap;
overflow: hidden;
max-height: 44px;
line-height: 1.4;
border: 1px solid var(--border);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.chat-tool-card--clickable:hover .chat-tool-card__preview {
background: var(--bg-hover);
border-color: var(--border-strong);
background: rgba(0, 0, 0, 0.12);
border-color: rgba(255, 255, 255, 0.08);
}
/* Short inline output */
@ -146,8 +116,8 @@
color: var(--text);
margin-top: 6px;
padding: 6px 8px;
background: var(--secondary);
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
@ -187,7 +157,9 @@
}
@keyframes reading-pulse {
0%, 60%, 100% {
0%,
60%,
100% {
opacity: 0.3;
transform: scale(0.8);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,10 @@
/* ===========================================
Shell Layout
=========================================== */
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 220px;
--shell-topbar-height: 56px;
--shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out);
--shell-focus-duration: 220ms;
--shell-focus-ease: cubic-bezier(0.2, 0.85, 0.25, 1);
height: 100vh;
display: grid;
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
@ -17,7 +13,7 @@
"topbar topbar"
"nav content";
gap: 0;
animation: dashboard-enter 0.4s var(--ease-out);
animation: dashboard-enter 0.6s ease-out;
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
overflow: hidden;
}
@ -65,10 +61,6 @@
gap: 0;
}
/* ===========================================
Topbar
=========================================== */
.topbar {
grid-area: topbar;
position: sticky;
@ -81,7 +73,8 @@
padding: 0 20px;
height: var(--shell-topbar-height);
border-bottom: 1px solid var(--border);
background: var(--bg);
background: var(--panel);
backdrop-filter: blur(18px);
}
.topbar-left {
@ -91,84 +84,49 @@
}
.topbar .nav-collapse-toggle {
width: 36px;
height: 36px;
width: 44px;
height: 44px;
margin-bottom: 0;
}
.topbar .nav-collapse-toggle__icon {
width: 20px;
height: 20px;
font-size: 22px;
}
.topbar .nav-collapse-toggle__icon svg {
width: 20px;
height: 20px;
}
/* Brand */
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-text {
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
}
.brand-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 700;
letter-spacing: -0.03em;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 600;
line-height: 1.1;
color: var(--text-strong);
}
.brand-sub {
font-size: 10px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.05em;
letter-spacing: 0.8px;
text-transform: uppercase;
line-height: 1;
}
/* Topbar status */
.topbar-status {
display: flex;
align-items: center;
gap: 8px;
}
/* Smaller pill and theme toggle in topbar */
.topbar-status .pill {
padding: 6px 10px;
padding: 4px 10px;
gap: 6px;
font-size: 12px;
font-weight: 500;
height: 32px;
box-sizing: border-box;
}
.topbar-status .pill .mono {
display: flex;
align-items: center;
line-height: 1;
margin-top: 0px;
font-size: 11px;
}
.topbar-status .statusDot {
@ -177,9 +135,9 @@
}
.topbar-status .theme-toggle {
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
--theme-item: 22px;
--theme-gap: 4px;
--theme-pad: 4px;
}
.topbar-status .theme-icon {
@ -187,26 +145,18 @@
height: 12px;
}
/* ===========================================
Navigation Sidebar
=========================================== */
.nav {
grid-area: nav;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 12px;
background: var(--bg);
scrollbar-width: none; /* Firefox */
padding: 16px;
border-right: 1px solid var(--border);
background: var(--panel);
backdrop-filter: blur(18px);
transition:
width var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0;
}
.nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
padding var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0; /* Allow grid item to shrink and enable scrolling */
}
.shell--chat-focus .nav {
@ -215,9 +165,9 @@
border-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
}
/* Collapsed nav sidebar - completely hidden */
.nav--collapsed {
width: 0;
min-width: 0;
@ -228,7 +178,7 @@
pointer-events: none;
}
/* Nav collapse toggle */
/* Nav collapse toggle button */
.nav-collapse-toggle {
width: 32px;
height: 32px;
@ -237,88 +187,73 @@
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
border-radius: 6px;
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
background 150ms ease,
border-color 150ms ease;
margin-bottom: 16px;
}
.nav-collapse-toggle:hover {
background: var(--bg-hover);
background: rgba(255, 255, 255, 0.08);
border-color: var(--border);
}
:root[data-theme="light"] .nav-collapse-toggle:hover {
background: rgba(0, 0, 0, 0.06);
}
.nav-collapse-toggle__icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 16px;
color: var(--muted);
transition: color var(--duration-fast) ease;
}
.nav-collapse-toggle__icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: var(--text);
}
/* Nav groups */
.nav-group {
margin-bottom: 20px;
margin-bottom: 18px;
display: grid;
gap: 2px;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
}
.nav-group:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.nav-group__items {
display: grid;
gap: 1px;
gap: 4px;
}
.nav-group--collapsed .nav-group__items {
display: none;
}
/* Nav label */
.nav-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 10px;
padding: 4px 0;
font-size: 11px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1.4px;
color: var(--text);
opacity: 0.7;
margin-bottom: 4px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
border-radius: var(--radius-sm);
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.nav-label:hover {
color: var(--text);
background: var(--bg-hover);
opacity: 1;
}
.nav-label--static {
@ -326,8 +261,7 @@
}
.nav-label--static:hover {
color: var(--muted);
background: transparent;
opacity: 0.7;
}
.nav-label__text {
@ -335,113 +269,99 @@
}
.nav-label__chevron {
font-size: 10px;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
font-size: 12px;
opacity: 0.6;
}
.nav-group--collapsed .nav-label__chevron {
transform: rotate(-90deg);
}
/* Nav items */
.nav-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-md);
gap: 8px;
padding: 10px 12px 10px 14px;
border-radius: 12px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
cursor: pointer;
text-decoration: none;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
border-color 160ms ease,
background 160ms ease,
color 160ms ease;
}
.nav-item__icon {
width: 16px;
height: 16px;
font-size: 16px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.7;
transition: opacity var(--duration-fast) ease;
}
.nav-item__icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-item__text {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.nav-item:hover {
color: var(--text);
background: var(--bg-hover);
text-decoration: none;
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.nav-item:hover .nav-item__icon {
opacity: 1;
.nav-item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 60%;
border-radius: 0 999px 999px 0;
transform: translateY(-50%);
background: transparent;
}
.nav-item.active {
color: var(--text-strong);
background: var(--accent-subtle);
color: var(--text);
border-color: rgba(245, 159, 74, 0.45);
background: rgba(245, 159, 74, 0.12);
}
.nav-item.active .nav-item__icon {
opacity: 1;
color: var(--accent);
.nav-item.active::before {
background: var(--accent);
box-shadow: 0 0 12px rgba(245, 159, 74, 0.4);
}
/* ===========================================
Content Area
=========================================== */
.content {
grid-area: content;
padding: 12px 16px 32px;
padding: 8px 6px 20px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 20px;
min-height: 0;
overflow-y: auto;
overflow-y: auto; /* Enable vertical scrolling for pages with long content */
overflow-x: hidden;
}
:root[data-theme="light"] .content {
background: var(--bg-content);
}
/* Chat handles its own scrolling (chat-thread); avoid double scrollbars. */
.content--chat {
overflow: hidden;
padding-bottom: 0;
}
/* Content header */
.shell--chat .content {
/* No-op: keep chat layout consistent with other tabs */
}
.content-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 4px 8px;
gap: 12px;
padding: 0 6px;
overflow: hidden;
transform-origin: top center;
transition:
@ -449,39 +369,35 @@
transform var(--shell-focus-duration) var(--shell-focus-ease),
max-height var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease);
max-height: 80px;
max-height: 90px;
}
.shell--chat-focus .content-header {
opacity: 0;
transform: translateY(-8px);
transform: translateY(-10px);
max-height: 0px;
padding: 0;
pointer-events: none;
}
.page-title {
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.15;
color: var(--text-strong);
letter-spacing: 0.6px;
}
.page-sub {
color: var(--muted);
font-size: 14px;
font-weight: 400;
margin-top: 6px;
letter-spacing: -0.01em;
font-size: 12px;
letter-spacing: 0.4px;
}
.page-meta {
display: flex;
gap: 8px;
gap: 10px;
}
/* Chat view header adjustments */
/* Chat view: header and controls side by side */
.content--chat .content-header {
flex-direction: row;
align-items: center;
@ -501,13 +417,9 @@
flex-shrink: 0;
}
/* ===========================================
Grid Utilities
=========================================== */
.grid {
display: grid;
gap: 20px;
gap: 18px;
}
.grid-cols-2 {
@ -521,13 +433,13 @@
.stat-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.note-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.row {
@ -538,25 +450,21 @@
.stack {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 10px;
align-items: center;
}
/* ===========================================
Responsive - Tablet
=========================================== */
@media (max-width: 1100px) {
.shell {
--shell-pad: 12px;
--shell-gap: 12px;
--shell-nav-col: 1fr;
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
@ -569,18 +477,17 @@
position: static;
max-height: none;
display: flex;
gap: 6px;
gap: 16px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 10px 14px;
background: var(--bg);
padding: 12px;
}
.nav-group {
grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
margin-bottom: 0;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
border-bottom: none;
padding-bottom: 0;
}
.grid-cols-2,
@ -590,11 +497,13 @@
.topbar {
position: static;
padding: 12px 14px;
gap: 10px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.topbar-status {
width: 100%;
flex-wrap: wrap;
}

View File

@ -122,9 +122,12 @@ export async function handleNostrProfileSave(host: ClawdbotApp) {
},
body: JSON.stringify(state.values),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; details?: unknown; persisted?: boolean }
| null;
const data = (await response.json().catch(() => null)) as {
ok?: boolean;
error?: string;
details?: unknown;
persisted?: boolean;
} | null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
@ -187,9 +190,13 @@ export async function handleNostrProfileImport(host: ClawdbotApp) {
},
body: JSON.stringify({ autoMerge: true }),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean }
| null;
const data = (await response.json().catch(() => null)) as {
ok?: boolean;
error?: string;
imported?: NostrProfile;
merged?: NostrProfile;
saved?: boolean;
} | null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;

View File

@ -82,7 +82,10 @@ async function sendChatMessageNow(
host.chatAttachments = opts.previousAttachments;
}
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
@ -170,7 +173,9 @@ type SessionDefaultsSnapshot = {
function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) return parsed.agentId;
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const snapshot = host.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }
| undefined;
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
return fallback || "main";
}

View File

@ -10,12 +10,7 @@ import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { flushChatQueueForEvent } from "./app-chat";
import {
applySettings,
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
} from "./app-settings";
import { applySettings, loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import {
addExecApproval,
@ -75,8 +70,7 @@ function normalizeSessionKeyForDefaults(
raw === "main" ||
raw === mainKey ||
(defaultAgentId &&
(raw === `agent:${defaultAgentId}:main` ||
raw === `agent:${defaultAgentId}:${mainKey}`));
(raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`));
return isAlias ? mainSessionKey : raw;
}
@ -185,9 +179,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
const state = handleChatEvent(host as unknown as ClawdbotApp, payload);
if (state === "final" || state === "error" || state === "aborted") {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
}
if (state === "final") void loadChatHistory(host as unknown as ClawdbotApp);
return;

View File

@ -35,20 +35,11 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
syncTabWithLocation(
host as unknown as Parameters<typeof syncTabWithLocation>[0],
true,
);
syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
);
attachThemeListener(
host as unknown as Parameters<typeof attachThemeListener>[0],
);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler);
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {
@ -68,17 +59,12 @@ export function handleDisconnected(host: LifecycleHost) {
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
host.topbarObserver?.disconnect();
host.topbarObserver = null;
}
export function handleUpdated(
host: LifecycleHost,
changed: Map<PropertyKey, unknown>,
) {
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
if (
host.tab === "chat" &&
(changed.has("chatMessages") ||

View File

@ -45,8 +45,39 @@ export function renderChatControls(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
const refreshIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
@ -105,9 +136,11 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"}
title=${
disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
}
>
${icons.brain}
</button>
@ -122,9 +155,11 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${focusActive}
title=${disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"}
title=${
disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
}
>
${focusIcon}
</button>

View File

@ -1,35 +1,9 @@
import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { AppViewState } from "./app-view-state";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import {
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
import { icons } from "./icons";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
ConfigSnapshot,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { refreshChatAvatar } from "./app-chat";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
@ -60,7 +34,6 @@ import {
saveSkillApiKey,
updateSkillEdit,
updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills";
import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat";
@ -78,7 +51,13 @@ import {
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import {
loadCronRuns,
toggleCronJob,
runCronJob,
removeCronJob,
addCronJob,
} from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
@ -88,10 +67,7 @@ const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
const agentId =
parsed?.agentId ??
state.agentsList?.defaultId ??
"main";
const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar;
@ -198,234 +174,246 @@ export function renderApp(state: AppViewState) {
<div class="page-sub">${subtitleForTab(state.tab)}</div>
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
${
state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing
}
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
${
state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing
}
${state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
${
state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing
}
${state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
${
state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing
}
${state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
${
state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: (state.channelsSnapshot?.channelOrder ?? []),
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing
}
${state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
${
state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing
}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
${
state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm:
state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing
}
${state.tab === "chat"
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
@ -499,83 +487,89 @@ export function renderApp(state: AppViewState) {
})
: nothing}
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
${
state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing
}
${state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
${
state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing
}
${state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
${
state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing
}
</main>
${renderExecApprovalPrompt(state)}
</div>

View File

@ -35,8 +35,7 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
host.chatScrollFrame = null;
const target = pickScrollTarget();
if (!target) return;
const distanceFromBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
if (!shouldStick) return;
if (force) host.chatHasAutoScrolled = true;
@ -49,8 +48,7 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
if (!latest) return;
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
if (!shouldStickRetry) return;
latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true;
@ -78,16 +76,14 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200;
}
export function handleLogsScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80;
}

View File

@ -9,12 +9,24 @@ import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills";
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import {
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
pathForTab,
tabFromPath,
type Tab,
} from "./navigation";
import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import {
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
@ -113,8 +125,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
if (next === "logs") startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
@ -123,11 +134,7 @@ export function setTab(host: SettingsHost, next: Tab) {
syncUrlWithTab(host, next, false);
}
export function setTheme(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
@ -172,10 +179,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "logs") {
host.logsAtBottom = true;
await loadLogs(host as unknown as ClawdbotApp, { reset: true });
scheduleLogsScroll(
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
true,
);
scheduleLogsScroll(host as unknown as Parameters<typeof scheduleLogsScroll>[0], true);
}
}
@ -261,8 +265,7 @@ export function onPopState(host: SettingsHost) {
export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
if (next === "logs") startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
@ -293,11 +296,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
}
}
export function syncUrlWithSessionKey(
host: SettingsHost,
sessionKey: string,
replace: boolean,
) {
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set("session", sessionKey);

View File

@ -191,8 +191,7 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
}
if (payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) return;
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;

View File

@ -22,10 +22,7 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";

View File

@ -1,4 +1,4 @@
import { LitElement, html, nothing } from "lit";
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
@ -27,10 +27,7 @@ import type {
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
@ -259,9 +256,7 @@ export class ClawdbotApp extends LitElement {
private toolStreamOrder: string[] = [];
basePath = "";
private popStateHandler = () =>
onPopStateInternal(
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
@ -285,16 +280,11 @@ export class ClawdbotApp extends LitElement {
}
protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
}
connect() {
connectGatewayInternal(
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
}
handleChatScroll(event: Event) {
@ -316,15 +306,11 @@ export class ClawdbotApp extends LitElement {
}
resetToolStream() {
resetToolStreamInternal(
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
}
resetChatScroll() {
resetChatScrollInternal(
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
}
async loadAssistantIdentity() {
@ -332,10 +318,7 @@ export class ClawdbotApp extends LitElement {
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
}
setTab(next: Tab) {
@ -343,29 +326,19 @@ export class ClawdbotApp extends LitElement {
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
}
async loadOverview() {
await loadOverviewInternal(
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
}
async loadCron() {
await loadCronInternal(
this as unknown as Parameters<typeof loadCronInternal>[0],
);
await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
}
async handleAbortChat() {
await handleAbortChatInternal(
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
}
removeQueuedMessage(id: string) {

View File

@ -28,13 +28,10 @@ function coerceIdentityValue(value: string | undefined, maxLength: number): stri
export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null,
): AssistantIdentity {
const name =
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
const agentId =
typeof input?.agentId === "string" && input.agentId.trim()
? input.agentId.trim()
: null;
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
return { agentId, name, avatar };
}

View File

@ -46,9 +46,7 @@ describe("chat markdown rendering", () => {
await app.updateComplete;
const toolCards = Array.from(
app.querySelectorAll<HTMLElement>(".chat-tool-card"),
);
const toolCards = Array.from(app.querySelectorAll<HTMLElement>(".chat-tool-card"));
const toolCard = toolCards.find((card) =>
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
);

View File

@ -1,11 +1,14 @@
import { html, type TemplateResult } from "lit";
import { icons } from "../icons";
import { renderEmojiIcon, setEmojiIcon } from "../icons";
const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed";
const COPY_ICON = "📋";
const COPIED_ICON = "✓";
const ERROR_ICON = "!";
type CopyButtonOptions = {
text: () => string;
@ -38,9 +41,7 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
aria-label=${idleLabel}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
const iconContainer = btn?.querySelector(
".chat-copy-btn__icon",
) as HTMLElement | null;
const icon = btn?.querySelector(".chat-copy-btn__icon") as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") return;
@ -58,29 +59,30 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
if (!copied) {
btn.dataset.error = "1";
setButtonLabel(btn, ERROR_LABEL);
setEmojiIcon(icon, ERROR_ICON);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.error;
setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, ERROR_FOR_MS);
return;
}
btn.dataset.copied = "1";
setButtonLabel(btn, COPIED_LABEL);
setEmojiIcon(icon, COPIED_ICON);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.copied;
setButtonLabel(btn, idleLabel);
setEmojiIcon(icon, COPY_ICON);
}, COPIED_FOR_MS);
}}
>
<span class="chat-copy-btn__icon" aria-hidden="true">
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
<span class="chat-copy-btn__icon-check">${icons.check}</span>
</span>
${renderEmojiIcon(COPY_ICON, "chat-copy-btn__icon")}
</button>
`;
}

View File

@ -122,11 +122,7 @@ export function renderMessageGroup(
? assistantName
: normalizedRole;
const roleClass =
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: "other";
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
@ -143,8 +139,7 @@ export function renderMessageGroup(
renderGroupedMessage(
item.message,
{
isStreaming:
group.isStreaming && index === group.messages.length - 1,
isStreaming: group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
},
opts.onOpenSidebar,
@ -159,10 +154,7 @@ export function renderMessageGroup(
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
) {
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
@ -179,7 +171,7 @@ function renderAvatar(
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
: normalized === "tool"
? "tool"
: "other";
@ -199,9 +191,7 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) ||
/^\//.test(value) // Relative paths from avatar endpoint
/^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith('/') // Relative paths from avatar endpoint
);
}
@ -245,13 +235,9 @@ function renderGroupedMessage(
const extractedText = extractTextCached(message);
const extractedThinking =
opts.showReasoning && role === "assistant"
? extractThinkingCached(message)
: null;
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null;
const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
@ -265,9 +251,7 @@ function renderGroupedMessage(
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) =>
renderToolCardSidebar(card, onOpenSidebar),
)}`;
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
}
if (!markdown && !hasToolCards && !hasImages) return nothing;

View File

@ -90,13 +90,9 @@ export function extractThinking(message: unknown): string | null {
const rawText = extractRawText(message);
if (!rawText) return null;
const matches = [
...rawText.matchAll(
/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi,
),
...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi),
];
const extracted = matches
.map((m) => (m[1] ?? "").trim())
.filter(Boolean);
const extracted = matches.map((m) => (m[1] ?? "").trim()).filter(Boolean);
return extracted.length > 0 ? extracted.join("\n") : null;
}

View File

@ -44,8 +44,18 @@ describe("message-normalizer", () => {
expect(result.role).toBe("assistant");
expect(result.content).toHaveLength(2);
expect(result.content[0]).toEqual({ type: "text", text: "Here is the result", name: undefined, args: undefined });
expect(result.content[1]).toEqual({ type: "tool_use", text: undefined, name: "bash", args: { command: "ls" } });
expect(result.content[0]).toEqual({
type: "text",
text: "Here is the result",
name: undefined,
args: undefined,
});
expect(result.content[1]).toEqual({
type: "tool_use",
text: undefined,
name: "bash",
args: { command: "ls" },
});
});
it("normalizes message with text field (alternative format)", () => {

View File

@ -2,10 +2,7 @@
* Message normalization utilities for chat rendering.
*/
import type {
NormalizedMessage,
MessageContentItem,
} from "../types/chat-types";
import type { NormalizedMessage, MessageContentItem } from "../types/chat-types";
/**
* Normalize a raw message object into a consistent structure.
@ -16,8 +13,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
// Detect tool messages by common gateway shapes.
// Some tool events come through as assistant role with tool_* items in the content array.
const hasToolId =
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const contentRaw = m.content;
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;

View File

@ -1,13 +1,9 @@
import { html, nothing } from "lit";
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import { icons } from "../icons";
import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants";
import {
formatToolOutputForSidebar,
getTruncatedPreview,
} from "./tool-helpers";
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers";
import { isToolResultMessage } from "./message-normalizer";
import { extractTextCached } from "./message-extract";
@ -38,10 +34,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
cards.push({ kind: "result", name, text });
}
if (
isToolResultMessage(message) &&
!cards.some((card) => card.kind === "result")
) {
if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
@ -53,10 +46,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
return cards;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: string) => void,
) {
export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content: string) => void) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim());
@ -86,36 +76,48 @@ export function renderToolCardSidebar(
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick?.();
}
: nothing}
@keydown=${
canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick?.();
}
: nothing
}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span class="chat-tool-card__icon">${display.emoji}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
: nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
${
canClick
? html`<span class="chat-tool-card__action">${hasText ? "View " : ""}</span>`
: nothing
}
${
isEmpty && !canClick
? html`
<span class="chat-tool-card__status"></span>
`
: nothing
}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline
? html`<div class="chat-tool-card__inline mono">${card.text}</div>`
: nothing}
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${
isEmpty
? html`
<div class="chat-tool-card__status-text muted">Completed</div>
`
: nothing
}
${
showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing
}
${showInline ? html`<div class="chat-tool-card__inline mono">${card.text}</div>` : nothing}
</div>
`;
}

View File

@ -16,7 +16,7 @@ describe("tool-helpers", () => {
});
it("formats valid JSON array as code block", () => {
const input = '[1, 2, 3]';
const input = "[1, 2, 3]";
const result = formatToolOutputForSidebar(input);
expect(result).toBe(`\`\`\`json

View File

@ -24,7 +24,7 @@ export class ResizableDivider extends LitElement {
flex-shrink: 0;
position: relative;
}
:host::before {
content: "";
position: absolute;
@ -33,18 +33,20 @@ export class ResizableDivider extends LitElement {
right: -4px;
bottom: 0;
}
:host(:hover) {
background: var(--accent, #007bff);
}
:host(.dragging) {
background: var(--accent, #007bff);
}
`;
render() {
return html``;
return html`
`;
}
connectedCallback() {
@ -89,7 +91,7 @@ export class ResizableDivider extends LitElement {
detail: { splitRatio: newRatio },
bubbles: true,
composed: true,
})
}),
);
};

View File

@ -29,12 +29,7 @@ const rootSchema = {
type: "boolean",
},
bind: {
anyOf: [
{ const: "auto" },
{ const: "lan" },
{ const: "tailnet" },
{ const: "loopback" },
],
anyOf: [{ const: "auto" }, { const: "lan" }, { const: "tailnet" }, { const: "loopback" }],
},
},
};
@ -57,17 +52,12 @@ describe("config form renderer", () => {
container,
);
const tokenInput = container.querySelector(
"input[type='password']",
) as HTMLInputElement | null;
const tokenInput = container.querySelector("input[type='password']") as HTMLInputElement | null;
expect(tokenInput).not.toBeNull();
if (!tokenInput) return;
tokenInput.value = "abc123";
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(
["gateway", "auth", "token"],
"abc123",
);
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
const tokenButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
@ -76,9 +66,7 @@ describe("config form renderer", () => {
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector(
"input[type='checkbox']",
) as HTMLInputElement | null;
const checkbox = container.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(checkbox).not.toBeNull();
if (!checkbox) return;
checkbox.checked = true;
@ -101,9 +89,7 @@ describe("config form renderer", () => {
container,
);
const addButton = container.querySelector(
".cfg-array__add",
) as HTMLButtonElement | null;
const addButton = container.querySelector(".cfg-array__add") as HTMLButtonElement | null;
expect(addButton).not.toBeUndefined();
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);

View File

@ -1,8 +1,5 @@
import type { GatewayBrowserClient } from "../gateway";
import {
normalizeAssistantIdentity,
type AssistantIdentity,
} from "../assistant-identity";
import { normalizeAssistantIdentity, type AssistantIdentity } from "../assistant-identity";
export type AssistantIdentityState = {
client: GatewayBrowserClient | null;

View File

@ -144,9 +144,7 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
try {
await state.client.request(
"chat.abort",
runId
? { sessionKey: state.sessionKey, runId }
: { sessionKey: state.sessionKey },
runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey },
);
return true;
} catch (err) {
@ -155,10 +153,7 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
}
}
export function handleChatEvent(
state: ChatState,
payload?: ChatEventPayload,
) {
export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null;

View File

@ -46,11 +46,11 @@ describe("applyConfigSnapshot", () => {
config: { gateway: { mode: "remote", port: 9999 } },
valid: true,
issues: [],
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
raw: '{\n "gateway": { "mode": "remote", "port": 9999 }\n}\n',
});
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
);
});
@ -129,7 +129,7 @@ describe("updateConfigFormValue", () => {
updateConfigFormValue(state, ["gateway", "port"], 18789);
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
);
});
});
@ -142,7 +142,7 @@ describe("applyConfig", () => {
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
state.configFormMode = "raw";
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
state.configRaw = '{\n agent: { workspace: "~/clawd" }\n}\n';
state.configSnapshot = {
hash: "hash-123",
};
@ -150,7 +150,7 @@ describe("applyConfig", () => {
await applyConfig(state);
expect(request).toHaveBeenCalledWith("config.apply", {
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
raw: '{\n agent: { workspace: "~/clawd" }\n}\n',
baseHash: "hash-123",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});

View File

@ -1,9 +1,5 @@
import type { GatewayBrowserClient } from "../gateway";
import type {
ConfigSchemaResponse,
ConfigSnapshot,
ConfigUiHints,
} from "../types";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types";
import {
cloneConfigObject,
removePathValue,
@ -57,10 +53,7 @@ export async function loadConfigSchema(state: ConfigState) {
if (state.configSchemaLoading) return;
state.configSchemaLoading = true;
try {
const res = (await state.client.request(
"config.schema",
{},
)) as ConfigSchemaResponse;
const res = (await state.client.request("config.schema", {})) as ConfigSchemaResponse;
applyConfigSchema(state, res);
} catch (err) {
state.lastError = String(err);
@ -69,10 +62,7 @@ export async function loadConfigSchema(state: ConfigState) {
}
}
export function applyConfigSchema(
state: ConfigState,
res: ConfigSchemaResponse,
) {
export function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
@ -175,9 +165,7 @@ export function updateConfigFormValue(
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
@ -186,13 +174,8 @@ export function updateConfigFormValue(
}
}
export function removeConfigFormValue(
state: ConfigState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
export function removeConfigFormValue(state: ConfigState, path: Array<string | number>) {
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;

View File

@ -22,16 +22,14 @@ export function setPathValue(
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
current[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
record[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
@ -59,9 +57,7 @@ export function removePathValue(
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
current = (current as Record<string, unknown>)[key] as
| Record<string, unknown>
| unknown[];
current = (current as Record<string, unknown>)[key] as Record<string, unknown> | unknown[];
}
if (current == null) return;
}

View File

@ -103,8 +103,7 @@ export async function addCronJob(state: CronState) {
wakeMode: state.cronForm.wakeMode,
payload,
isolation:
state.cronForm.postToMainPrefix.trim() &&
state.cronForm.sessionTarget === "isolated"
state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated"
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
: undefined,
};
@ -125,11 +124,7 @@ export async function addCronJob(state: CronState) {
}
}
export async function toggleCronJob(
state: CronState,
job: CronJob,
enabled: boolean,
) {
export async function toggleCronJob(state: CronState, job: CronJob, enabled: boolean) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;

View File

@ -29,9 +29,7 @@ export async function loadDebug(state: DebugState) {
state.debugStatus = status as StatusSummary;
state.debugHealth = health as HealthSnapshot;
const modelPayload = models as { models?: unknown[] } | undefined;
state.debugModels = Array.isArray(modelPayload?.models)
? modelPayload?.models
: [];
state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : [];
state.debugHeartbeat = heartbeat as unknown;
} catch (err) {
state.debugCallError = String(err);

View File

@ -118,9 +118,7 @@ export async function revokeDeviceToken(
params: { deviceId: string; role: string },
) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm(
`Revoke token for ${params.deviceId} (${params.role})?`,
);
const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`);
if (!confirmed) return;
try {
await state.client.request("device.token.revoke", params);

View File

@ -80,6 +80,9 @@ export function addExecApproval(
return next;
}
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
export function removeExecApproval(
queue: ExecApprovalRequest[],
id: string,
): ExecApprovalRequest[] {
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
}

View File

@ -34,9 +34,7 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile;
};
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
@ -120,10 +118,7 @@ export async function saveExecApprovals(
state.lastError = "Exec approvals hash missing; reload and retry.";
return;
}
const file =
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {};
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
if (!rpc) {
state.lastError = "Select a node before saving exec approvals.";

View File

@ -16,14 +16,7 @@ export type LogsState = {
};
const LOG_BUFFER_LIMIT = 2000;
const LEVELS = new Set<LogLevel>([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
]);
const LEVELS = new Set<LogLevel>(["trace", "debug", "info", "warn", "error", "fatal"]);
function parseMaybeJsonString(value: unknown) {
if (typeof value !== "string") return null;
@ -53,11 +46,7 @@ export function parseLogLine(line: string): LogEntry {
? (obj._meta as Record<string, unknown>)
: null;
const time =
typeof obj.time === "string"
? obj.time
: typeof meta?.date === "string"
? meta?.date
: null;
typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta?.date : null;
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
const contextCandidate =
@ -94,17 +83,14 @@ export function parseLogLine(line: string): LogEntry {
}
}
export async function loadLogs(
state: LogsState,
opts?: { reset?: boolean; quiet?: boolean },
) {
export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.logsLoading && !opts?.quiet) return;
if (!opts?.quiet) state.logsLoading = true;
state.logsError = null;
try {
const res = await state.client.request("logs.tail", {
cursor: opts?.reset ? undefined : state.logsCursor ?? undefined,
cursor: opts?.reset ? undefined : (state.logsCursor ?? undefined),
limit: state.logsLimit,
maxBytes: state.logsMaxBytes,
});

View File

@ -8,10 +8,7 @@ export type NodesState = {
lastError: string | null;
};
export async function loadNodes(
state: NodesState,
opts?: { quiet?: boolean },
) {
export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.nodesLoading) return;
state.nodesLoading = true;

View File

@ -17,9 +17,7 @@ export async function loadPresence(state: PresenceState) {
state.presenceError = null;
state.presenceStatus = null;
try {
const res = (await state.client.request("system-presence", {})) as
| PresenceEntry[]
| undefined;
const res = (await state.client.request("system-presence", {})) as PresenceEntry[] | undefined;
if (Array.isArray(res)) {
state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null;

View File

@ -45,9 +45,7 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
state.skillsLoading = true;
state.skillsError = null;
try {
const res = (await state.client.request("skills.status", {})) as
| SkillStatusReport
| undefined;
const res = (await state.client.request("skills.status", {})) as SkillStatusReport | undefined;
if (res) state.skillsReport = res;
} catch (err) {
state.skillsError = getErrorMessage(err);
@ -56,19 +54,11 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
}
}
export function updateSkillEdit(
state: SkillsState,
skillKey: string,
value: string,
) {
export function updateSkillEdit(state: SkillsState, skillKey: string, value: string) {
state.skillEdits = { ...state.skillEdits, [skillKey]: value };
}
export async function updateSkillEnabled(
state: SkillsState,
skillKey: string,
enabled: boolean,
) {
export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey;
state.skillsError = null;

View File

@ -36,9 +36,7 @@ describe("chat focus mode", () => {
expect(shell).not.toBeNull();
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const toggle = app.querySelector<HTMLButtonElement>(
'button[title^="Toggle focus mode"]',
);
const toggle = app.querySelector<HTMLButtonElement>('button[title^="Toggle focus mode"]');
expect(toggle).not.toBeNull();
toggle?.click();
@ -47,9 +45,7 @@ describe("chat focus mode", () => {
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await app.updateComplete;
expect(app.tab).toBe("channels");

View File

@ -42,7 +42,10 @@ export function clampText(value: string, max = 120): string {
return `${value.slice(0, Math.max(0, max - 1))}`;
}
export function truncateText(value: string, max: number): {
export function truncateText(
value: string,
max: number,
): {
text: string;
truncated: boolean;
total: number;

View File

@ -5,40 +5,223 @@ import { html, type TemplateResult } from "lit";
export const icons = {
// Navigation icons
messageSquare: html`<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
barChart: html`<svg viewBox="0 0 24 24"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>`,
link: html`<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
radio: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>`,
fileText: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/></svg>`,
zap: html`<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
monitor: html`<svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`,
settings: html`<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
bug: html`<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>`,
scrollText: html`<svg viewBox="0 0 24 24"><path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/></svg>`,
folder: html`<svg viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`,
messageSquare: html`
<svg viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
`,
barChart: html`
<svg viewBox="0 0 24 24">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="16" />
</svg>
`,
link: html`
<svg viewBox="0 0 24 24">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
`,
radio: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="2" />
<path
d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"
/>
</svg>
`,
fileText: html`
<svg viewBox="0 0 24 24">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
`,
zap: html`
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
`,
monitor: html`
<svg viewBox="0 0 24 24">
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
`,
settings: html`
<svg viewBox="0 0 24 24">
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
`,
bug: html`
<svg viewBox="0 0 24 24">
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
`,
scrollText: html`
<svg viewBox="0 0 24 24">
<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M15 8h-5" />
<path d="M15 12h-5" />
</svg>
`,
folder: html`
<svg viewBox="0 0 24 24">
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
`,
// UI icons
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
search: html`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
brain: html`<svg viewBox="0 0 24 24"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>`,
book: html`<svg viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
loader: html`<svg viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`,
menu: html`
<svg viewBox="0 0 24 24">
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
`,
x: html`
<svg viewBox="0 0 24 24">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
`,
check: html`
<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
`,
copy: html`
<svg viewBox="0 0 24 24">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
`,
search: html`
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
`,
brain: html`
<svg viewBox="0 0 24 24">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" />
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
</svg>
`,
book: html`
<svg viewBox="0 0 24 24">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
</svg>
`,
loader: html`
<svg viewBox="0 0 24 24">
<path d="M12 2v4" />
<path d="m16.2 7.8 2.9-2.9" />
<path d="M18 12h4" />
<path d="m16.2 16.2 2.9 2.9" />
<path d="M12 18v4" />
<path d="m4.9 19.1 2.9-2.9" />
<path d="M2 12h4" />
<path d="m4.9 4.9 2.9 2.9" />
</svg>
`,
// Tool icons
wrench: html`<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
fileCode: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>`,
edit: html`<svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
penLine: html`<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
paperclip: html`<svg viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`,
globe: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`,
image: html`<svg viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
smartphone: html`<svg viewBox="0 0 24 24"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>`,
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>`,
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`,
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
wrench: html`
<svg viewBox="0 0 24 24">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
`,
fileCode: html`
<svg viewBox="0 0 24 24">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
</svg>
`,
edit: html`
<svg viewBox="0 0 24 24">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
`,
penLine: html`
<svg viewBox="0 0 24 24">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
`,
paperclip: html`
<svg viewBox="0 0 24 24">
<path
d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"
/>
</svg>
`,
globe: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
`,
image: html`
<svg viewBox="0 0 24 24">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
`,
smartphone: html`
<svg viewBox="0 0 24 24">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
`,
plug: html`
<svg viewBox="0 0 24 24">
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
`,
circle: html`
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /></svg>
`,
puzzle: html`
<svg viewBox="0 0 24 24">
<path
d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"
/>
</svg>
`,
} as const;
export type IconName = keyof typeof icons;
@ -52,7 +235,10 @@ export function renderIcon(name: IconName, className = "nav-item__icon"): Templa
}
// Legacy function for compatibility
export function renderEmojiIcon(iconContent: string | TemplateResult, className: string): TemplateResult {
export function renderEmojiIcon(
iconContent: string | TemplateResult,
className: string,
): TemplateResult {
return html`<span class=${className} aria-hidden="true">${iconContent}</span>`;
}

View File

@ -75,13 +75,9 @@ describe("control UI routing", () => {
const app = mountApp("/chat");
await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>(
'a.nav-item[href="/channels"]',
);
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await app.updateComplete;
expect(app.tab).toBe("channels");

View File

@ -37,9 +37,7 @@ const TAB_PATHS: Record<Tab, string> = {
logs: "/logs",
};
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
);
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
export function normalizeBasePath(basePath: string): string {
if (!basePath) return "";

View File

@ -49,22 +49,16 @@ export function loadSettings(): UiSettings {
? parsed.sessionKey.trim()
: defaults.sessionKey,
lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" &&
parsed.lastActiveSessionKey.trim()
typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" &&
parsed.sessionKey.trim()) ||
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme:
parsed.theme === "light" ||
parsed.theme === "dark" ||
parsed.theme === "system"
parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
? parsed.theme
: defaults.theme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
: defaults.chatFocusMode,
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
@ -76,12 +70,9 @@ export function loadSettings(): UiSettings {
? parsed.splitRatio
: defaults.splitRatio,
navCollapsed:
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed
: defaults.navCollapsed,
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
};

View File

@ -55,8 +55,7 @@ export const startThemeTransition = ({
const document_ = documentReference as DocumentWithViewTransition;
const prefersReducedMotion = hasReducedMotionPreference();
const canUseViewTransition =
Boolean(document_.startViewTransition) && !prefersReducedMotion;
const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion;
if (canUseViewTransition) {
let xPercent = 0.5;
@ -71,11 +70,7 @@ export const startThemeTransition = ({
yPercent = clamp01(context.pointerClientY / window.innerHeight);
} else if (context?.element) {
const rect = context.element.getBoundingClientRect();
if (
rect.width > 0 &&
rect.height > 0 &&
typeof window !== "undefined"
) {
if (rect.width > 0 && rect.height > 0 && typeof window !== "undefined") {
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
}

View File

@ -5,9 +5,7 @@ export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme {

View File

@ -90,7 +90,13 @@
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
"detailKeys": [
"request.kind",
"request.ref",
"request.selector",
"request.text",
"request.value"
]
}
}
},
@ -117,9 +123,15 @@
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_snap": {
"label": "camera snap",
"detailKeys": ["node", "nodeId", "facing", "deviceId"]
},
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"camera_clip": {
"label": "camera clip",
"detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"]
},
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]

View File

@ -153,8 +153,7 @@ export function resolveToolDisplay(params: {
detail = resolveWriteDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys);
}
@ -192,7 +191,5 @@ export function formatToolSummary(display: ToolDisplay): string {
function shortenHomeInString(input: string): string {
if (!input) return input;
return input
.replace(/\/Users\/[^/]+/g, "~")
.replace(/\/home\/[^/]+/g, "~");
return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~");
}

View File

@ -514,13 +514,7 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
export type LogLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
raw: string;

View File

@ -2,12 +2,7 @@ import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
analyzeConfigSchema,
renderNode,
schemaType,
type JsonSchema,
} from "./config-form";
import { analyzeConfigSchema, renderNode, schemaType, type JsonSchema } from "./config-form";
type ChannelConfigFormProps = {
channelId: string;
@ -61,9 +56,7 @@ function resolveChannelValue(
(fromChannels && typeof fromChannels === "object"
? (fromChannels as Record<string, unknown>)
: null) ??
(fallback && typeof fallback === "object"
? (fallback as Record<string, unknown>)
: null);
(fallback && typeof fallback === "object" ? (fallback as Record<string, unknown>) : null);
return resolved ?? {};
}
@ -71,11 +64,15 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
return html`
<div class="callout danger">Schema unavailable. Use Raw.</div>
`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
return html`
<div class="callout danger">Channel config schema unavailable.</div>
`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
@ -95,24 +92,25 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
`;
}
export function renderChannelConfigSection(params: {
channelId: string;
props: ChannelsProps;
}) {
export function renderChannelConfigSection(params: { channelId: string; props: ChannelsProps }) {
const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading;
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})}
${
props.configSchemaLoading
? html`
<div class="muted">Loading config schema</div>
`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})
}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"

View File

@ -37,18 +37,22 @@ export function renderDiscordCard(params: {
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
: nothing
}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "discord", props })}

View File

@ -34,9 +34,11 @@ export function renderGoogleChatCard(params: {
<div>
<span class="label">Audience</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
${
googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"
}
</span>
</div>
<div>
@ -49,18 +51,22 @@ export function renderGoogleChatCard(params: {
</div>
</div>
${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing}
: nothing
}
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "googlechat", props })}

View File

@ -37,18 +37,22 @@ export function renderIMessageCard(params: {
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
: nothing
}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "imessage", props })}

View File

@ -82,7 +82,7 @@ export function renderNostrProfileForm(params: {
placeholder?: string;
maxLength?: number;
help?: string;
} = {}
} = {},
) => {
const { type = "text", placeholder, maxLength, help } = opts;
const value = state.values[field] ?? "";
@ -169,13 +169,17 @@ export function renderNostrProfileForm(params: {
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
</div>
${state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing}
${
state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing
}
${state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing}
${
state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing
}
${renderPicturePreview()}
@ -204,8 +208,9 @@ export function renderNostrProfileForm(params: {
help: "HTTPS URL to your profile picture",
})}
${state.showAdvanced
? html`
${
state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
@ -232,7 +237,8 @@ export function renderNostrProfileForm(params: {
})}
</div>
`
: nothing}
: nothing
}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
@ -267,11 +273,15 @@ export function renderNostrProfileForm(params: {
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
</div>`
: nothing}
${
isDirty
? html`
<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px">
You have unsaved changes
</div>
`
: nothing
}
</div>
`;
}
@ -284,7 +294,7 @@ export function renderNostrProfileForm(params: {
* Create initial form state from existing profile
*/
export function createNostrProfileFormState(
profile: NostrProfileType | undefined
profile: NostrProfileType | undefined,
): NostrProfileFormState {
const values: NostrProfileType = {
name: profile?.name ?? "",
@ -305,8 +315,6 @@ export function createNostrProfileFormState(
error: null,
success: null,
fieldErrors: {},
showAdvanced: Boolean(
profile?.banner || profile?.website || profile?.nip05 || profile?.lud16
),
showAdvanced: Boolean(profile?.banner || profile?.website || profile?.nip05 || profile?.lud16),
};
}

View File

@ -44,8 +44,7 @@ export function renderNostrCard(params: {
const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
const summaryPublicKey =
nostr?.publicKey ??
(primaryAccount as { publicKey?: string } | undefined)?.publicKey;
nostr?.publicKey ?? (primaryAccount as { publicKey?: string } | undefined)?.publicKey;
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
@ -79,11 +78,13 @@ export function renderNostrCard(params: {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing}
: nothing
}
</div>
</div>
`;
@ -100,17 +101,19 @@ export function renderNostrCard(params: {
}
const profile =
(primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined)?.profile ?? nostr?.profile;
(
primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined
)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
@ -118,8 +121,9 @@ export function renderNostrCard(params: {
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
${summaryConfigured
? html`
${
summaryConfigured
? html`
<button
class="btn btn-sm"
@click=${onEditProfile}
@ -128,13 +132,16 @@ export function renderNostrCard(params: {
Edit Profile
</button>
`
: nothing}
: nothing
}
</div>
${hasAnyProfileData
? html`
${
hasAnyProfileData
? html`
<div class="status-list">
${picture
? html`
${
picture
? html`
<div style="margin-bottom: 8px;">
<img
src=${picture}
@ -146,22 +153,28 @@ export function renderNostrCard(params: {
/>
</div>
`
: nothing}
: nothing
}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${
displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing
}
${
about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing
}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`}
: html`
<div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`
}
</div>
`;
};
@ -172,13 +185,14 @@ export function renderNostrCard(params: {
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
${
hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@ -199,11 +213,14 @@ export function renderNostrCard(params: {
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`}
`
}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${
summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing
}
${renderProfileSection()}

View File

@ -41,18 +41,22 @@ export function renderSignalCard(params: {
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
: nothing
}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "signal", props })}

View File

@ -37,18 +37,22 @@ export function renderSlackCard(params: {
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
: nothing
}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "slack", props })}

View File

@ -39,13 +39,15 @@ export function renderTelegramCard(params: {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
: nothing
}
</div>
</div>
`;
@ -57,13 +59,14 @@ export function renderTelegramCard(params: {
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
${
hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@ -86,20 +89,25 @@ export function renderTelegramCard(params: {
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
`
}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
: nothing
}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "telegram", props })}

View File

@ -15,11 +15,7 @@ import type {
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ChannelsChannelData,
ChannelsProps,
} from "./channels.types";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types";
import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
@ -33,12 +29,8 @@ import { renderWhatsAppCard } from "./channels.whatsapp";
export function renderChannels(props: ChannelsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const whatsapp = (channels?.whatsapp ?? undefined) as WhatsAppStatus | undefined;
const telegram = (channels?.telegram ?? undefined) as TelegramStatus | undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const googlechat = (channels?.googlechat ?? null) as GoogleChatStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
@ -82,11 +74,13 @@ export function renderChannels(props: ChannelsProps) {
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
: nothing
}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
@ -101,27 +95,11 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return [
"whatsapp",
"telegram",
"discord",
"googlechat",
"slack",
"signal",
"imessage",
"nostr",
];
return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"];
}
function renderChannel(
key: ChannelKey,
props: ChannelsProps,
data: ChannelsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
function renderChannel(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) {
const accountCountLabel = renderChannelAccountCount(key, data.channelAccounts);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
@ -218,13 +196,14 @@ function renderGenericChannelCard(
<div class="card-sub">Channel status and configuration.</div>
${accountCountLabel}
${accounts.length > 0
? html`
${
accounts.length > 0
? html`
<div class="account-card-list">
${accounts.map((account) => renderGenericAccount(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@ -239,13 +218,16 @@ function renderGenericChannelCard(
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
</div>
</div>
`}
`
}
${lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${lastError}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: key, props })}
</div>
@ -259,10 +241,7 @@ function resolveChannelMetaMap(
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
}
function resolveChannelLabel(
snapshot: ChannelsStatusSnapshot | null,
key: string,
): string {
function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string {
const meta = resolveChannelMetaMap(snapshot)[key];
return meta?.label ?? snapshot?.channelLabels?.[key] ?? key;
}
@ -316,13 +295,15 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
: nothing
}
</div>
</div>
`;

View File

@ -1,16 +1,16 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type { NostrProfileFormState } from "./channels.nostr-profile-form";

View File

@ -39,9 +39,7 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
</span>
</div>
<div>
@ -53,30 +51,34 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
: nothing
}
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${
props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing}
: nothing
}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
${
props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
: nothing
}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button

View File

@ -3,16 +3,13 @@ import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import {
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
} from "../chat/grouped-render";
import { icons } from "../icons";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
@ -78,7 +75,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (status.active) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context...
🧹 Compacting context...
</div>
`;
}
@ -89,7 +86,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted
🧹 Context compacted
</div>
`;
}
@ -178,9 +175,7 @@ export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const activeSession = props.sessions?.sessions?.find(
(row) => row.key === props.sessionKey,
);
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
@ -204,49 +199,56 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
${
props.loading
? html`
<div class="muted">Loading chat</div>
`
: nothing
}
${repeat(
buildChatItems(props),
(item) => item.key,
(item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
return nothing;
},
)}
</div>
`;
return html`
<section class="card chat">
${props.disabledReason
? html`<div class="callout">${props.disabledReason}</div>`
: nothing}
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode
? html`
${
props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@ -254,10 +256,11 @@ export function renderChat(props: ChatProps) {
aria-label="Exit focus mode"
title="Exit focus mode"
>
${icons.x}
</button>
`
: nothing}
: nothing
}
<div
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
@ -269,12 +272,12 @@ export function renderChat(props: ChatProps) {
${thread}
</div>
${sidebarOpen
? html`
${
sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) =>
props.onSplitRatioChange?.(e.detail.splitRatio)}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar">
${renderMarkdownSidebar({
@ -288,11 +291,13 @@ export function renderChat(props: ChatProps) {
})}
</div>
`
: nothing}
: nothing
}
</div>
${props.queue.length
? html`
${
props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
@ -311,7 +316,7 @@ export function renderChat(props: ChatProps) {
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
`,
@ -319,7 +324,8 @@ export function renderChat(props: ChatProps) {
</div>
</div>
`
: nothing}
: nothing
}
<div class="chat-compose">
${renderAttachmentPreview(props)}

View File

@ -47,8 +47,7 @@ function normalizeSchemaNode(
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
const type =
schemaType(schema) ??
(schema.properties || schema.additionalProperties ? "object" : undefined);
schemaType(schema) ?? (schema.properties || schema.additionalProperties ? "object" : undefined);
normalized.type = type ?? schema.type;
normalized.nullable = nullable || schema.nullable;
@ -73,24 +72,15 @@ function normalizeSchemaNode(
unsupported.add(pathLabel);
} else if (schema.additionalProperties === false) {
normalized.additionalProperties = false;
} else if (
schema.additionalProperties &&
typeof schema.additionalProperties === "object"
) {
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (!isAnySchema(schema.additionalProperties as JsonSchema)) {
const res = normalizeSchemaNode(
schema.additionalProperties as JsonSchema,
[...path, "*"],
);
normalized.additionalProperties =
res.schema ?? (schema.additionalProperties as JsonSchema);
const res = normalizeSchemaNode(schema.additionalProperties as JsonSchema, [...path, "*"]);
normalized.additionalProperties = res.schema ?? (schema.additionalProperties as JsonSchema);
if (res.unsupportedPaths.length > 0) unsupported.add(pathLabel);
}
}
} else if (type === "array") {
const itemsSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
unsupported.add(pathLabel);
} else {

View File

@ -28,11 +28,69 @@ function jsonValue(value: unknown): string {
// SVG Icons as template literals
const icons = {
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`,
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
chevronDown: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`,
plus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
minus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
trash: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
`,
edit: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
`,
};
export function renderNode(params: {
@ -64,7 +122,7 @@ export function renderNode(params: {
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null")))
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (nonNull.length === 1) {
@ -88,16 +146,18 @@ export function renderNode(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${literals.map((lit, idx) => html`
${literals.map(
(lit) => html`
<button
type="button"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${String(lit)}
</button>
`)}
`,
)}
</div>
</div>
`;
@ -109,11 +169,9 @@ export function renderNode(params: {
}
// Handle mixed primitive types
const primitiveTypes = new Set(
nonNull.map((variant) => schemaType(variant)).filter(Boolean)
);
const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
const normalizedTypes = new Set(
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v))
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v)),
);
if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) {
@ -147,16 +205,18 @@ export function renderNode(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${options.map((opt) => html`
${options.map(
(opt) => html`
<button
type="button"
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? 'active' : ''}"
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? "active" : ""}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
</button>
`)}
`,
)}
</div>
</div>
`;
@ -176,9 +236,14 @@ export function renderNode(params: {
// Boolean - toggle row
if (type === "boolean") {
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html`
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<label class="cfg-toggle-row ${disabled ? "disabled" : ""}">
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
@ -261,7 +326,9 @@ function renderTextInput(params: {
onPatch(path, raw);
}}
/>
${schema.default !== undefined ? html`
${
schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
@ -269,7 +336,9 @@ function renderTextInput(params: {
?disabled=${disabled}
@click=${() => onPatch(path, schema.default)}
></button>
` : nothing}
`
: nothing
}
</div>
</div>
`;
@ -360,9 +429,11 @@ function renderSelect(params: {
}}
>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html`
${options.map(
(opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`)}
`,
)}
</select>
</div>
`;
@ -379,15 +450,16 @@ function renderObject(params: {
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const _showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
@ -416,18 +488,22 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
})
}),
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
${
allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing
}
</div>
`;
}
@ -450,18 +526,22 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
})
}),
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
${
allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing
}
</div>
</details>
`;
@ -499,7 +579,7 @@ function renderArray(params: {
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? "s" : ""}</span>
<button
type="button"
class="cfg-array__add"
@ -514,14 +594,16 @@ function renderArray(params: {
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html`
<div class="cfg-array__empty">
No items yet. Click "Add" to create one.
</div>
` : html`
${
arr.length === 0
? html`
<div class="cfg-array__empty">No items yet. Click "Add" to create one.</div>
`
: html`
<div class="cfg-array__items">
${arr.map((item, idx) => html`
${arr.map(
(item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
@ -552,9 +634,11 @@ function renderArray(params: {
})}
</div>
</div>
`)}
`,
)}
</div>
`}
`
}
</div>
`;
}
@ -582,7 +666,7 @@ function renderMapField(params: {
class="cfg-map__add"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
const next = { ...value };
let index = 1;
let key = `custom-${index}`;
while (key in next) {
@ -597,10 +681,13 @@ function renderMapField(params: {
Add Entry
</button>
</div>
${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div>
` : html`
${
entries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
@ -617,7 +704,7 @@ function renderMapField(params: {
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) };
const next = { ...value };
if (nextKey in next) return;
next[nextKey] = next[key];
delete next[key];
@ -626,8 +713,9 @@ function renderMapField(params: {
/>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
${
anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
@ -649,16 +737,17 @@ function renderMapField(params: {
}}
></textarea>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})
}
</div>
<button
type="button"
@ -666,7 +755,7 @@ function renderMapField(params: {
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...(value ?? {}) };
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
@ -677,7 +766,8 @@ function renderMapField(params: {
`;
})}
</div>
`}
`
}
</div>
`;
}

View File

@ -1,12 +1,6 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { icons } from "../icons";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared";
import { renderNode } from "./config-form.node";
export type ConfigFormProps = {
@ -23,44 +17,237 @@ export type ConfigFormProps = {
// SVG Icons for section cards (Lucide-style)
const sectionIcons = {
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>`,
logging: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
browser: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4"></circle><line x1="21.17" y1="8" x2="12" y2="8"></line><line x1="3.95" y1="6.06" x2="8.54" y2="14"></line><line x1="10.88" y1="21.94" x2="15.46" y2="14"></line></svg>`,
ui: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>`,
models: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
bindings: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
broadcast: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path><circle cx="12" cy="12" r="2"></circle><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path></svg>`,
audio: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
session: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
cron: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
web: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
discovery: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
canvasHost: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
talk: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
plugins: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2v6"></path><path d="m4.93 10.93 4.24 4.24"></path><path d="M2 12h6"></path><path d="m4.93 13.07 4.24-4.24"></path><path d="M12 22v-6"></path><path d="m19.07 13.07-4.24-4.24"></path><path d="M22 12h-6"></path><path d="m19.07 10.93-4.24 4.24"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
env: {
label: "Environment Variables",
description: "Environment variables passed to the gateway process",
},
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
auth: { label: "Authentication", description: "API keys and authentication profiles" },
channels: { label: "Channels", description: "Messaging channels (Telegram, Discord, Slack, etc.)" },
channels: {
label: "Channels",
description: "Messaging channels (Telegram, Discord, Slack, etc.)",
},
messages: { label: "Messages", description: "Message handling and routing settings" },
commands: { label: "Commands", description: "Custom slash commands" },
hooks: { label: "Hooks", description: "Webhooks and event hooks" },
@ -142,12 +329,16 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
return html`
<div class="muted">Schema unavailable.</div>
`;
}
const schema = props.schema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
return html`
<div class="callout danger">Unsupported schema. Use Raw.</div>
`;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
@ -168,9 +359,8 @@ export function renderConfigForm(props: ConfigFormProps) {
return true;
});
let subsectionContext:
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
| null = null;
let subsectionContext: { sectionKey: string; subsectionKey: string; schema: JsonSchema } | null =
null;
if (activeSection && activeSubsection && filteredEntries.length === 1) {
const sectionSchema = filteredEntries[0]?.[1];
if (
@ -190,11 +380,9 @@ export function renderConfigForm(props: ConfigFormProps) {
if (filteredEntries.length === 0) {
return html`
<div class="config-empty">
<div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__icon">🔍</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
${searchQuery ? `No settings match "${searchQuery}"` : "No settings in this section"}
</div>
</div>
`;
@ -202,27 +390,30 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
<div class="config-form config-form--modern">
${subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
${
subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}>
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
${
description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing
}
</div>
</div>
<div class="config-section-card__content">
@ -239,22 +430,24 @@ export function renderConfigForm(props: ConfigFormProps) {
</div>
</section>
`;
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return html`
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
${
meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing
}
</div>
</div>
<div class="config-section-card__content">
@ -271,7 +464,8 @@ export function renderConfigForm(props: ConfigFormProps) {
</div>
</section>
`;
})}
})
}
</div>
`;
}

View File

@ -1,7 +1,4 @@
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
} from "./config-form.analyze";
export { analyzeConfigSchema, type ConfigSchemaAnalysis } from "./config-form.analyze";
export { renderNode } from "./config-form.node";
export { schemaType, type JsonSchema } from "./config-form.shared";

View File

@ -59,11 +59,9 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
});
@ -81,11 +79,9 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
});
@ -102,16 +98,12 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
@ -124,22 +116,18 @@ describe("config view", () => {
renderConfig({
...baseProps(),
formMode: "raw",
raw: "{\n gateway: { mode: \"local\" }\n}\n",
raw: '{\n gateway: { mode: "local" }\n}\n',
originalRaw: "{\n}\n",
}),
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
@ -157,8 +145,8 @@ describe("config view", () => {
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim() === "Raw",
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Raw",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
@ -183,8 +171,8 @@ describe("config view", () => {
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim() === "Gateway",
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Gateway",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
@ -202,9 +190,7 @@ describe("config view", () => {
container,
);
const input = container.querySelector(
".config-search__input",
) as HTMLInputElement | null;
const input = container.querySelector(".config-search__input") as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = "gateway";

View File

@ -1,12 +1,7 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared";
export type ConfigProps = {
raw: string;
@ -41,36 +36,230 @@ export type ConfigProps = {
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`,
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
all: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>`,
logging: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
browser: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4"></circle><line x1="21.17" y1="8" x2="12" y2="8"></line><line x1="3.95" y1="6.06" x2="8.54" y2="14"></line><line x1="10.88" y1="21.94" x2="15.46" y2="14"></line></svg>`,
ui: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>`,
models: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
bindings: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
broadcast: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path><circle cx="12" cy="12" r="2"></circle><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path></svg>`,
audio: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
session: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
cron: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
web: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
discovery: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
canvasHost: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
talk: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
plugins: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6"></path><path d="m4.93 10.93 4.24 4.24"></path><path d="M2 12h6"></path><path d="m4.93 13.07 4.24-4.24"></path><path d="M12 22v-6"></path><path d="m19.07 13.07-4.24-4.24"></path><path d="M22 12h-6"></path><path d="m19.07 10.93-4.24 4.24"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Section definitions
@ -102,7 +291,10 @@ function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function resolveSectionMeta(key: string, schema?: JsonSchema): {
function resolveSectionMeta(
key: string,
schema?: JsonSchema,
): {
label: string;
description?: string;
} {
@ -134,7 +326,7 @@ function resolveSubsections(params: {
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) return [];
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
@ -182,22 +374,19 @@ function truncateValue(value: unknown, maxLen = 40): string {
}
export function renderConfig(props: ConfigProps) {
const validity =
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema);
const formUnsafe = analysis.schema
? analysis.unsupportedPaths.length > 0
: false;
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
// Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter(s => s.key in schemaProps);
const availableSections = SECTIONS.filter((s) => s.key in schemaProps);
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map(s => s.key));
const knownKeys = new Set(SECTIONS.map((s) => s.key));
const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
.filter((k) => !knownKeys.has(k))
.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
@ -216,27 +405,22 @@ export function renderConfig(props: ConfigProps) {
})
: [];
const allowSubnav =
props.formMode === "form" &&
Boolean(props.activeSection) &&
subsections.length > 0;
props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery
? null
: isAllSubsection
? null
: props.activeSubsection ?? (subsections[0]?.key ?? null);
: (props.activeSubsection ?? subsections[0]?.key ?? null);
// Compute diff for showing changes (works for both form and raw modes)
const diff = props.formMode === "form"
? computeDiff(props.originalValue, props.formValue)
: [];
const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm =
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected &&
!props.saving &&
@ -258,7 +442,7 @@ export function renderConfig(props: ConfigProps) {
<div class="config-sidebar__title">Settings</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span>
</div>
<!-- Search -->
<div class="config-search">
<svg class="config-search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -272,14 +456,18 @@ export function renderConfig(props: ConfigProps) {
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery ? html`
<button
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>×</button>
` : nothing}
`
: nothing
}
</div>
<!-- Section nav -->
<nav class="config-nav">
<button
@ -289,7 +477,8 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(section => html`
${allSections.map(
(section) => html`
<button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
@ -297,9 +486,10 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${getSectionIcon(section.key)}</span>
<span class="config-nav__label">${section.label}</span>
</button>
`)}
`,
)}
</nav>
<!-- Mode toggle at bottom -->
<div class="config-sidebar__footer">
<div class="config-mode-toggle">
@ -319,17 +509,21 @@ export function renderConfig(props: ConfigProps) {
</div>
</div>
</aside>
<!-- Main content -->
<main class="config-main">
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${hasChanges ? html`
${
hasChanges
? html`
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
` : html`
<span class="config-status muted">No changes</span>
`}
`
: html`
<span class="config-status muted">No changes</span>
`
}
</div>
<div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
@ -358,9 +552,11 @@ export function renderConfig(props: ConfigProps) {
</button>
</div>
</div>
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${hasChanges && props.formMode === "form" ? html`
${
hasChanges && props.formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
@ -369,7 +565,8 @@ export function renderConfig(props: ConfigProps) {
</svg>
</summary>
<div class="config-diff__content">
${diff.map(change => html`
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__values">
@ -378,27 +575,35 @@ export function renderConfig(props: ConfigProps) {
<span class="config-diff__to">${truncateValue(change.to)}</span>
</div>
</div>
`)}
`,
)}
</div>
</details>
` : nothing}
`
: nothing
}
${activeSectionMeta && props.formMode === "form"
? html`
${
activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing}
${
activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing
}
</div>
</div>
`
: nothing}
: nothing
}
${allowSubnav
? html`
${
allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@ -421,36 +626,45 @@ export function renderConfig(props: ConfigProps) {
)}
</div>
`
: nothing}
: nothing
}
<!-- Form content -->
<div class="config-content">
${props.formMode === "form"
? html`
${props.schemaLoading
? html`<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema</span>
</div>`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">
Form view can't safely edit some fields.
Use Raw to avoid losing config entries.
</div>`
: nothing}
${
props.formMode === "form"
? html`
${
props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
})
}
${
formUnsafe
? html`
<div class="callout danger" style="margin-top: 12px">
Form view can't safely edit some fields. Use Raw to avoid losing config entries.
</div>
`
: nothing
}
`
: html`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<textarea
@ -459,14 +673,17 @@ export function renderConfig(props: ConfigProps) {
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`}
`
}
</div>
${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
${
props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>`
: nothing}
: nothing
}
</main>
</div>
`;

View File

@ -63,11 +63,7 @@ export function renderCron(props: CronProps) {
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"}
</div>
</div>
<div class="stat">
@ -131,7 +127,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
scheduleKind: (e.target as HTMLSelectElement)
.value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
@ -148,7 +145,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
sessionTarget: (e.target as HTMLSelectElement)
.value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
@ -174,7 +172,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
payloadKind: (e.target as HTMLSelectElement)
.value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
@ -193,8 +192,9 @@ export function renderCron(props: CronProps) {
rows="4"
></textarea>
</label>
${props.form.payloadKind === "agentTurn"
? html`
${
props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
@ -212,9 +212,10 @@ export function renderCron(props: CronProps) {
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
props.onFormChange({
channel: (e.target as HTMLSelectElement)
.value as CronFormState["channel"],
})}
>
${channelOptions.map(
(channel) =>
@ -243,8 +244,9 @@ export function renderCron(props: CronProps) {
})}
/>
</label>
${props.form.sessionTarget === "isolated"
? html`
${
props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
@ -256,10 +258,12 @@ export function renderCron(props: CronProps) {
/>
</label>
`
: nothing}
: nothing
}
</div>
`
: nothing}
: nothing
}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
@ -271,31 +275,37 @@ export function renderCron(props: CronProps) {
<section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
: html`
${
props.jobs.length === 0
? html`
<div class="muted" style="margin-top: 12px">No jobs yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
</div>
`}
`
}
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
${props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
</div>
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
${
props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px">Select a job to inspect run history.</div>
`
: props.runs.length === 0
? html`
<div class="muted" style="margin-top: 12px">No runs yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
</div>
`}
`
}
</section>
`;
}

View File

@ -57,8 +57,7 @@ export function renderDebug(props: DebugProps) {
<span>Method</span>
<input
.value=${props.callMethod}
@input=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence"
/>
</label>
@ -75,14 +74,18 @@ export function renderDebug(props: DebugProps) {
<div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button>
</div>
${props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
${
props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.callError}
</div>`
: nothing}
${props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing}
: nothing
}
${
props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing
}
</div>
</section>
@ -99,9 +102,12 @@ export function renderDebug(props: DebugProps) {
<section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
${props.eventLog.length === 0
? html`<div class="muted" style="margin-top: 12px;">No events yet.</div>`
: html`
${
props.eventLog.length === 0
? html`
<div class="muted" style="margin-top: 12px">No events yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;">
${props.eventLog.map(
(evt) => html`
@ -117,7 +123,8 @@ export function renderDebug(props: DebugProps) {
`,
)}
</div>
`}
`
}
</section>
`;
}

View File

@ -32,9 +32,11 @@ export function renderExecApprovalPrompt(state: AppViewState) {
<div class="exec-approval-title">Exec approval needed</div>
<div class="exec-approval-sub">${remaining}</div>
</div>
${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing}
${
queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing
}
</div>
<div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta">
@ -46,9 +48,11 @@ export function renderExecApprovalPrompt(state: AppViewState) {
${renderMetaRow("Security", request.security)}
${renderMetaRow("Ask", request.ask)}
</div>
${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing}
${
state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing
}
<div class="exec-approval-actions">
<button
class="btn primary"

View File

@ -23,30 +23,35 @@ export function renderInstances(props: InstancesProps) {
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
${props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">
: nothing
}
${
props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.statusMessage}
</div>`
: nothing}
: nothing
}
<div class="list" style="margin-top: 16px;">
${props.entries.length === 0
? html`<div class="muted">No instances reported yet.</div>`
: props.entries.map((entry) => renderEntry(entry))}
${
props.entries.length === 0
? html`
<div class="muted">No instances reported yet.</div>
`
: props.entries.map((entry) => renderEntry(entry))
}
</div>
</section>
`;
}
function renderEntry(entry: PresenceEntry) {
const lastInput =
entry.lastInputSeconds != null
? `${entry.lastInputSeconds}s ago`
: "n/a";
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
const mode = entry.mode ?? "unknown";
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
@ -66,12 +71,12 @@ function renderEntry(entry: PresenceEntry) {
${roles.map((role) => html`<span class="chip">${role}</span>`)}
${scopesLabel ? html`<span class="chip">${scopesLabel}</span>` : nothing}
${entry.platform ? html`<span class="chip">${entry.platform}</span>` : nothing}
${entry.deviceFamily
? html`<span class="chip">${entry.deviceFamily}</span>`
: nothing}
${entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing}
${entry.deviceFamily ? html`<span class="chip">${entry.deviceFamily}</span>` : nothing}
${
entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing
}
${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing}
</div>
</div>

View File

@ -60,7 +60,11 @@ export function renderLogs(props: LogsProps) {
<button
class="btn"
?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
@click=${() =>
props.onExport(
filtered.map((entry) => entry.raw),
exportLabel,
)}
>
Export ${exportLabel}
</button>
@ -72,8 +76,7 @@ export function renderLogs(props: LogsProps) {
<span>Filter</span>
<input
.value=${props.filterText}
@input=${(e: Event) =>
props.onFilterTextChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs"
/>
</label>
@ -104,23 +107,32 @@ export function renderLogs(props: LogsProps) {
)}
</div>
${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
: nothing}
${props.truncated
? html`<div class="callout" style="margin-top: 10px;">
Log output truncated; showing latest chunk.
</div>`
: nothing}
${props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
: nothing}
${
props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
: nothing
}
${
props.truncated
? html`
<div class="callout" style="margin-top: 10px">Log output truncated; showing latest chunk.</div>
`
: nothing
}
${
props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
: nothing
}
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${filtered.length === 0
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
: filtered.map(
(entry) => html`
${
filtered.length === 0
? html`
<div class="muted" style="padding: 12px">No log entries.</div>
`
: filtered.map(
(entry) => html`
<div class="log-row">
<div class="log-time mono">${formatTime(entry.time)}</div>
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div>
@ -128,7 +140,8 @@ export function renderLogs(props: LogsProps) {
<div class="log-message mono">${entry.message ?? entry.raw}</div>
</div>
`,
)}
)
}
</div>
</section>
`;

View File

@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons";
@ -21,16 +21,20 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
</button>
</div>
<div class="sidebar-content">
${props.error
? html`
${
props.error
? html`
<div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text
</button>
`
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`<div class="muted">No content available</div>`}
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`
<div class="muted">No content available</div>
`
}
</div>
</div>
`;

View File

@ -68,9 +68,13 @@ export function renderNodes(props: NodesProps) {
</button>
</div>
<div class="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
: props.nodes.map((n) => renderNode(n))}
${
props.nodes.length === 0
? html`
<div class="muted">No nodes found.</div>
`
: props.nodes.map((n) => renderNode(n))
}
</div>
</section>
`;
@ -91,25 +95,35 @@ function renderDevices(props: NodesProps) {
${props.devicesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
${
props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing
}
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
${
pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
${paired.length > 0
? html`
: nothing
}
${
paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
: nothing}
: nothing
}
${
pending.length === 0 && paired.length === 0
? html`
<div class="muted">No paired devices.</div>
`
: nothing
}
</div>
</section>
`;
@ -156,14 +170,18 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
: html`
${
tokens.length === 0
? html`
<div class="muted" style="margin-top: 6px">Tokens: none</div>
`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
`}
`
}
</div>
</div>
`;
@ -183,16 +201,18 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
>
Rotate
</button>
${token.revokedAtMs
? nothing
: html`
${
token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
`}
`
}
</div>
</div>
`;
@ -389,21 +409,17 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const targetNodes = resolveExecApprovalsNodes(props.nodes);
const target = props.execApprovalsTarget;
let targetNodeId =
target === "node" && props.execApprovalsTargetNodeId
? props.execApprovalsTargetNodeId
: null;
target === "node" && props.execApprovalsTargetNodeId ? props.execApprovalsTargetNodeId : null;
if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) {
targetNodeId = null;
}
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
null
? (((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? null)
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
[])
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? [])
: [];
return {
ready,
@ -450,20 +466,25 @@ function renderBindings(state: BindingState) {
</button>
</div>
${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>`
: nothing}
${
state.formMode === "raw"
? html`
<div class="callout warn" style="margin-top: 12px">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>
`
: nothing
}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
${
!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
</button>
</div>`
: html`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
@ -493,19 +514,26 @@ function renderBindings(state: BindingState) {
)}
</select>
</label>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
: nothing}
${
!supportsBinding
? html`
<div class="muted">No nodes with system.run available.</div>
`
: nothing
}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
${
state.agents.length === 0
? html`
<div class="muted">No agents found.</div>
`
: state.agents.map((agent) => renderAgentBinding(agent, state))
}
</div>
`}
`
}
</section>
`;
}
@ -533,20 +561,24 @@ function renderExecApprovals(state: ExecApprovalsState) {
${renderExecApprovalsTarget(state)}
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
${
!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
: html`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
`}
${
state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)
}
`
}
</section>
`;
}
@ -583,8 +615,9 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${state.target === "node"
? html`
${
state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
@ -608,12 +641,17 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</select>
</label>
`
: nothing}
: nothing
}
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
: nothing}
${
state.target === "node" && !hasNodes
? html`
<div class="muted">No nodes advertise exec approvals yet.</div>
`
: nothing
}
</div>
`;
}
@ -652,13 +690,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope];
const agentSecurity = typeof agent.security === "string" ? agent.security : undefined;
const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined;
const agentAskFallback =
typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : agentSecurity ?? "__default__";
const askValue = isDefaults ? defaults.ask : agentAsk ?? "__default__";
const askFallbackValue = isDefaults
? defaults.askFallback
: agentAskFallback ?? "__default__";
const agentAskFallback = typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : (agentSecurity ?? "__default__");
const askValue = isDefaults ? defaults.ask : (agentAsk ?? "__default__");
const askFallbackValue = isDefaults ? defaults.askFallback : (agentAskFallback ?? "__default__");
const autoOverride =
typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined;
const autoEffective = autoOverride ?? defaults.autoAllowSkills;
@ -670,9 +705,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults
? "Default security mode."
: `Default: ${defaults.security}.`}
${isDefaults ? "Default security mode." : `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
@ -690,11 +723,13 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
${
!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing}
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
@ -731,11 +766,13 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
${
!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing}
: nothing
}
${ASK_OPTIONS.map(
(option) =>
html`<option
@ -754,9 +791,11 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
${
isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`
}
</div>
</div>
<div class="list-meta">
@ -774,11 +813,13 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
${
!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing}
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
@ -797,11 +838,13 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
${
isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`
}
</div>
</div>
<div class="list-meta">
@ -817,15 +860,17 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}}
/>
</label>
${!isDefaults && !autoIsDefault
? html`<button
${
!isDefaults && !autoIsDefault
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing}
: nothing
}
</div>
</div>
</div>
@ -853,11 +898,13 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
</button>
</div>
<div class="list" style="margin-top: 12px;">
${entries.length === 0
? html`<div class="muted">No allowlist entries yet.</div>`
: entries.map((entry, index) =>
renderAllowlistEntry(state, entry, index),
)}
${
entries.length === 0
? html`
<div class="muted">No allowlist entries yet.</div>
`
: entries.map((entry, index) => renderAllowlistEntry(state, entry, index))
}
</div>
`;
}
@ -868,12 +915,8 @@ function renderAllowlistEntry(
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand
? clampText(entry.lastUsedCommand, 120)
: null;
const lastPath = entry.lastResolvedPath
? clampText(entry.lastResolvedPath, 120)
: null;
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`
<div class="list-item">
<div class="list-main">
@ -926,9 +969,11 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<div class="list-title">${label}</div>
<div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} ·
${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
${
bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`
}
</div>
</div>
<div class="list-meta">
@ -973,18 +1018,24 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` });
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}
function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): ExecApprovalsTargetNode[] {
function resolveExecApprovalsNodes(
nodes: Array<Record<string, unknown>>,
): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) => String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
(cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) continue;
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
@ -993,7 +1044,10 @@ function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): ExecA
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` });
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
@ -1035,9 +1089,7 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
const execEntry = (toolsEntry.exec ?? {}) as Record<string, unknown>;
const binding =
typeof execEntry.node === "string" && execEntry.node.trim()
? execEntry.node.trim()
: null;
typeof execEntry.node === "string" && execEntry.node.trim() ? execEntry.node.trim() : null;
agents.push({
id,
name: name || undefined,
@ -1077,9 +1129,7 @@ function renderNode(node: Record<string, unknown>) {
${connected ? "connected" : "offline"}
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands
.slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands.slice(0, 8).map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>
</div>
</div>

View File

@ -28,9 +28,7 @@ export function renderOverview(props: OverviewProps) {
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs
? `${snapshot.policy.tickIntervalMs}ms`
: "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authHint = (() => {
if (props.connected || !props.lastError) return null;
const lower = props.lastError.toLowerCase();
@ -40,13 +38,13 @@ export function renderOverview(props: OverviewProps) {
const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
return html`
<div class="muted" style="margin-top: 8px;">
<div class="muted" style="margin-top: 8px">
This gateway requires auth. Add a token or password, then click Connect.
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<span class="mono">clawdbot dashboard --no-open</span> tokenized URL<br />
<span class="mono">clawdbot doctor --generate-gateway-token</span> set token
</div>
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.clawd.bot/web/dashboard"
@ -60,11 +58,10 @@ export function renderOverview(props: OverviewProps) {
`;
}
return html`
<div class="muted" style="margin-top: 8px;">
<div class="muted" style="margin-top: 8px">
Auth failed. Re-copy a tokenized URL with
<span class="mono">clawdbot dashboard --no-open</span>, or update the token,
then click Connect.
<div style="margin-top: 6px;">
<span class="mono">clawdbot dashboard --no-open</span>, or update the token, then click Connect.
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.clawd.bot/web/dashboard"
@ -86,14 +83,14 @@ export function renderOverview(props: OverviewProps) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;">
<div class="muted" style="margin-top: 8px">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open
<span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
</div>
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.clawd.bot/gateway/tailscale"
@ -195,21 +192,23 @@ export function renderOverview(props: OverviewProps) {
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-value">
${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh)
: "n/a"}
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
</div>
</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
${
props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div>
${authHint ?? ""}
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`}
: html`
<div class="callout" style="margin-top: 14px">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>
`
}
</div>
</section>
@ -227,11 +226,7 @@ export function renderOverview(props: OverviewProps) {
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-value">
${props.cronEnabled == null
? "n/a"
: props.cronEnabled
? "Enabled"
: "Disabled"}
${props.cronEnabled == null ? "n/a" : props.cronEnabled ? "Enabled" : "Disabled"}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
</div>

View File

@ -141,9 +141,11 @@ export function renderSessions(props: SessionsProps) {
</label>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing
}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
@ -161,11 +163,15 @@ export function renderSessions(props: SessionsProps) {
<div>Reasoning</div>
<div>Actions</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
${
rows.length === 0
? html`
<div class="muted">No sessions found.</div>
`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)
}
</div>
</section>
`;
@ -193,9 +199,9 @@ function renderRow(
return html`
<div class="table-row">
<div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div class="mono">${
canLink ? html`<a href=${chatUrl} class="session-link">${displayName}</a>` : displayName
}</div>
<div>
<input
.value=${row.label ?? ""}
@ -221,9 +227,7 @@ function renderRow(
});
}}
>
${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
${thinkLevels.map((level) => html`<option value=${level}>${level || "inherit"}</option>`)}
</select>
</div>
<div>
@ -249,8 +253,8 @@ function renderRow(
onPatch(row.key, { reasoningLevel: value || null });
}}
>
${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
${REASONING_LEVELS.map(
(level) => html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>

View File

@ -25,10 +25,7 @@ export function renderSkills(props: SkillsProps) {
const filter = props.filter.trim().toLowerCase();
const filtered = filter
? skills.filter((skill) =>
[skill.name, skill.description, skill.source]
.join(" ")
.toLowerCase()
.includes(filter),
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: skills;
@ -49,25 +46,30 @@ export function renderSkills(props: SkillsProps) {
<span>Filter</span>
<input
.value=${props.filter}
@input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
/>
</label>
<div class="muted">${filtered.length} shown</div>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing
}
${filtered.length === 0
? html`<div class="muted" style="margin-top: 16px;">No skills found.</div>`
: html`
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
</div>
`}
`
}
</section>
`;
}
@ -76,8 +78,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey;
const apiKey = props.edits[skill.skillKey] ?? "";
const message = props.messages[skill.skillKey] ?? null;
const canInstall =
skill.install.length > 0 && skill.missing.bins.length > 0;
const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
const missing = [
...skill.missing.bins.map((b) => `bin:${b}`),
...skill.missing.env.map((e) => `env:${e}`),
@ -99,22 +100,32 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
</span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
${
skill.disabled
? html`
<span class="chip chip-warn">disabled</span>
`
: nothing
}
</div>
${missing.length > 0
? html`
${
missing.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")}
</div>
`
: nothing}
${reasons.length > 0
? html`
: nothing
}
${
reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
</div>
`
: nothing}
: nothing
}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
@ -125,19 +136,21 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
>
${skill.disabled ? "Enable" : "Disable"}
</button>
${canInstall
? html`<button
${
canInstall
? html`<button
class="btn"
?disabled=${busy}
@click=${() =>
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing…" : skill.install[0].label}
</button>`
: nothing}
: nothing
}
</div>
${message
? html`<div
${
message
? html`<div
class="muted"
style="margin-top: 8px; color: ${
message.kind === "error"
@ -147,9 +160,11 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
>
${message.message}
</div>`
: nothing}
${skill.primaryEnv
? html`
: nothing
}
${
skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<input
@ -168,7 +183,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
Save key
</button>
`
: nothing}
: nothing
}
</div>
</div>
`;