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 { :root {
/* Background - Warmer dark with depth */ --bg: #0a0f14;
--bg: #12141a; --bg-accent: #111826;
--bg-accent: #14161d; --bg-grad-1: #162031;
--bg-elevated: #1a1d25; --bg-grad-2: #1f2a22;
--bg-hover: #262a35; --bg-overlay: rgba(255, 255, 255, 0.05);
--bg-muted: #262a35; --bg-glow: rgba(245, 159, 74, 0.12);
--panel: rgba(14, 20, 30, 0.88);
/* Card / Surface - More contrast between levels */ --panel-strong: rgba(18, 26, 38, 0.96);
--card: #181b22; --chrome: rgba(9, 14, 20, 0.72);
--card-foreground: #f4f4f5; --chrome-strong: rgba(9, 14, 20, 0.86);
--card-highlight: rgba(255, 255, 255, 0.05); --text: rgba(244, 246, 251, 0.96);
--popover: #181b22; --chat-text: rgba(231, 237, 244, 0.92);
--popover-foreground: #f4f4f5; --muted: rgba(156, 169, 189, 0.72);
--border: rgba(255, 255, 255, 0.09);
/* Panel */ --border-strong: rgba(255, 255, 255, 0.16);
--panel: #12141a; --accent: #f59f4a;
--panel-strong: #1a1d25; --accent-2: #34c7b7;
--panel-hover: #262a35; --ok: #2bd97f;
--chrome: rgba(18, 20, 26, 0.95); --warn: #f2c94c;
--chrome-strong: rgba(18, 20, 26, 0.98); --danger: #ff6b6b;
--focus: rgba(245, 159, 74, 0.35);
/* 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 */
--grid-line: rgba(255, 255, 255, 0.04); --grid-line: rgba(255, 255, 255, 0.04);
/* Theme transition */
--theme-switch-x: 50%; --theme-switch-x: 50%;
--theme-switch-y: 50%; --theme-switch-y: 50%;
--mono:
/* Typography - Space Grotesk for personality */ "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; "Courier New", monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-body: "Work Sans", system-ui, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-display: "Unbounded", "Times New Roman", 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;
color-scheme: dark; color-scheme: dark;
} }
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #fafafa; --bg: #f5f1ea;
--bg-accent: #f5f5f5; --bg-accent: #ffffff;
--bg-elevated: #ffffff; --bg-grad-1: #f1e6d6;
--bg-hover: #f0f0f0; --bg-grad-2: #e5eef4;
--bg-muted: #f0f0f0; --bg-overlay: rgba(28, 32, 46, 0.05);
--bg-content: #f5f5f5; --bg-glow: rgba(52, 199, 183, 0.14);
--panel: rgba(255, 255, 255, 0.9);
--card: #ffffff; --panel-strong: rgba(255, 255, 255, 0.97);
--card-foreground: #18181b; --chrome: rgba(255, 255, 255, 0.75);
--card-highlight: rgba(0, 0, 0, 0.03); --chrome-strong: rgba(255, 255, 255, 0.88);
--popover: #ffffff; --text: rgba(27, 36, 50, 0.98);
--popover-foreground: #18181b; --chat-text: rgba(36, 48, 66, 0.9);
--muted: rgba(80, 94, 114, 0.7);
--panel: #fafafa; --border: rgba(18, 24, 40, 0.12);
--panel-strong: #f5f5f5; --border-strong: rgba(18, 24, 40, 0.2);
--panel-hover: #ebebeb; --accent: #e28a3f;
--chrome: rgba(250, 250, 250, 0.95); --accent-2: #1ba99d;
--chrome-strong: rgba(250, 250, 250, 0.98); --ok: #1aa86c;
--warn: #b3771c;
--text: #3f3f46; --danger: #d44848;
--text-strong: #18181b; --focus: rgba(226, 138, 63, 0.35);
--chat-text: #3f3f46; --grid-line: rgba(18, 24, 40, 0.06);
--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);
color-scheme: light; color-scheme: light;
} }
@ -198,19 +70,32 @@ body {
body { body {
margin: 0; margin: 0;
font: 400 14px/1.55 var(--font-body); font: 15px/1.5 var(--font-body);
letter-spacing: -0.02em; background:
background: var(--bg); 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); 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 { @keyframes theme-circle-transition {
0% { 0% {
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
} }
100% { 100% {
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); 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) { html.theme-transition::view-transition-new(theme) {
mix-blend-mode: normal; mix-blend-mode: normal;
z-index: 2; 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) { @media (prefers-reduced-motion: reduce) {
@ -247,12 +132,7 @@ clawdbot-app {
} }
a { a {
color: var(--accent); color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
} }
button, button,
@ -263,35 +143,10 @@ select {
color: inherit; 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 { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(6px);
} }
to { to {
opacity: 1; 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 { @keyframes dashboard-enter {
from { from {
opacity: 0; opacity: 0;
@ -329,44 +164,3 @@ select {
transform: translateY(0); 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); border-radius: var(--radius-lg);
padding: 10px 14px; padding: 10px 14px;
box-shadow: none; 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%; max-width: 100%;
word-wrap: break-word; word-wrap: break-word;
} }
@ -128,7 +130,9 @@ img.chat-avatar {
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
pointer-events: none; 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 { .chat-copy-btn__icon {
@ -243,7 +247,8 @@ img.chat-avatar {
} }
@keyframes pulsing-border { @keyframes pulsing-border {
0%, 100% { 0%,
100% {
border-color: var(--border); border-color: var(--border);
} }
50% { 50% {

View File

@ -77,7 +77,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,8 +45,39 @@ export function renderChatControls(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode; const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon // 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 refreshIcon = html`
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>`; <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` return html`
<div class="chat-controls"> <div class="chat-controls">
<label class="field chat-controls__session"> <label class="field chat-controls__session">
@ -105,9 +136,11 @@ export function renderChatControls(state: AppViewState) {
}); });
}} }}
aria-pressed=${showThinking} aria-pressed=${showThinking}
title=${disableThinkingToggle title=${
? "Disabled during onboarding" disableThinkingToggle
: "Toggle assistant thinking/working output"} ? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
}
> >
${icons.brain} ${icons.brain}
</button> </button>
@ -122,9 +155,11 @@ export function renderChatControls(state: AppViewState) {
}); });
}} }}
aria-pressed=${focusActive} aria-pressed=${focusActive}
title=${disableFocusToggle title=${
? "Disabled during onboarding" disableFocusToggle
: "Toggle focus mode (hide sidebar + page header)"} ? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
}
> >
${focusIcon} ${focusIcon}
</button> </button>

View File

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

View File

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

View File

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

View File

@ -191,8 +191,7 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
} }
if (payload.stream !== "tool") return; if (payload.stream !== "tool") return;
const sessionKey = const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) return; if (sessionKey && sessionKey !== host.sessionKey) return;
// Fallback: only accept session-less events for the active run. // Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return; 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 { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills"; import type { SkillMessage } from "./controllers/skills";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; 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 { customElement, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
@ -27,10 +27,7 @@ import type {
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { import {
@ -259,9 +256,7 @@ export class ClawdbotApp extends LitElement {
private toolStreamOrder: string[] = []; private toolStreamOrder: string[] = [];
basePath = ""; basePath = "";
private popStateHandler = () => private popStateHandler = () =>
onPopStateInternal( onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
private themeMedia: MediaQueryList | null = null; private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null; private topbarObserver: ResizeObserver | null = null;
@ -285,16 +280,11 @@ export class ClawdbotApp extends LitElement {
} }
protected updated(changed: Map<PropertyKey, unknown>) { protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated( handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
} }
connect() { connect() {
connectGatewayInternal( connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
} }
handleChatScroll(event: Event) { handleChatScroll(event: Event) {
@ -316,15 +306,11 @@ export class ClawdbotApp extends LitElement {
} }
resetToolStream() { resetToolStream() {
resetToolStreamInternal( resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
} }
resetChatScroll() { resetChatScroll() {
resetChatScrollInternal( resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
} }
async loadAssistantIdentity() { async loadAssistantIdentity() {
@ -332,10 +318,7 @@ export class ClawdbotApp extends LitElement {
} }
applySettings(next: UiSettings) { applySettings(next: UiSettings) {
applySettingsInternal( applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
} }
setTab(next: Tab) { setTab(next: Tab) {
@ -343,29 +326,19 @@ export class ClawdbotApp extends LitElement {
} }
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal( setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
} }
async loadOverview() { async loadOverview() {
await loadOverviewInternal( await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
} }
async loadCron() { async loadCron() {
await loadCronInternal( await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
this as unknown as Parameters<typeof loadCronInternal>[0],
);
} }
async handleAbortChat() { async handleAbortChat() {
await handleAbortChatInternal( await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
} }
removeQueuedMessage(id: string) { removeQueuedMessage(id: string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,7 +44,9 @@ export class ResizableDivider extends LitElement {
`; `;
render() { render() {
return html``; return html`
`;
} }
connectedCallback() { connectedCallback() {
@ -89,7 +91,7 @@ export class ResizableDivider extends LitElement {
detail: { splitRatio: newRatio }, detail: { splitRatio: newRatio },
bubbles: true, bubbles: true,
composed: true, composed: true,
}) }),
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,6 +80,9 @@ export function addExecApproval(
return next; 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); return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
} }

View File

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

View File

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

View File

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

View File

@ -17,9 +17,7 @@ export async function loadPresence(state: PresenceState) {
state.presenceError = null; state.presenceError = null;
state.presenceStatus = null; state.presenceStatus = null;
try { try {
const res = (await state.client.request("system-presence", {})) as const res = (await state.client.request("system-presence", {})) as PresenceEntry[] | undefined;
| PresenceEntry[]
| undefined;
if (Array.isArray(res)) { if (Array.isArray(res)) {
state.presenceEntries = res; state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null; 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.skillsLoading = true;
state.skillsError = null; state.skillsError = null;
try { try {
const res = (await state.client.request("skills.status", {})) as const res = (await state.client.request("skills.status", {})) as SkillStatusReport | undefined;
| SkillStatusReport
| undefined;
if (res) state.skillsReport = res; if (res) state.skillsReport = res;
} catch (err) { } catch (err) {
state.skillsError = getErrorMessage(err); state.skillsError = getErrorMessage(err);
@ -56,19 +54,11 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
} }
} }
export function updateSkillEdit( export function updateSkillEdit(state: SkillsState, skillKey: string, value: string) {
state: SkillsState,
skillKey: string,
value: string,
) {
state.skillEdits = { ...state.skillEdits, [skillKey]: value }; state.skillEdits = { ...state.skillEdits, [skillKey]: value };
} }
export async function updateSkillEnabled( export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
state: SkillsState,
skillKey: string,
enabled: boolean,
) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey; state.skillsBusyKey = skillKey;
state.skillsError = null; state.skillsError = null;

View File

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

View File

@ -5,40 +5,223 @@ import { html, type TemplateResult } from "lit";
export const icons = { export const icons = {
// Navigation 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>`, messageSquare: html`
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>`, <svg viewBox="0 0 24 24">
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>`, <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
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>`, </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>`, barChart: html`
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>`, <svg viewBox="0 0 24 24">
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>`, <line x1="12" x2="12" y1="20" y2="10" />
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>`, <line x1="18" x2="18" y1="20" y2="4" />
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>`, <line x1="6" x2="6" y1="20" y2="16" />
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>`, </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 // 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>`, menu: html`
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, <svg viewBox="0 0 24 24">
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`, <line x1="4" x2="20" y1="12" y2="12" />
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>`, <line x1="4" x2="20" y1="6" y2="6" />
search: html`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`, <line x1="4" x2="20" y1="18" y2="18" />
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>`, </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>`, 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 // 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>`, wrench: html`
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>`, <svg viewBox="0 0 24 24">
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>`, <path
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>`, 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"
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>`, </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>`, fileCode: html`
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>`, <svg viewBox="0 0 24 24">
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`, <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" />
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>`, <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; } as const;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
@ -52,7 +235,10 @@ export function renderIcon(name: IconName, className = "nav-item__icon"): Templa
} }
// Legacy function for compatibility // 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>`; return html`<span class=${className} aria-hidden="true">${iconContent}</span>`;
} }

View File

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

View File

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

View File

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

View File

@ -55,8 +55,7 @@ export const startThemeTransition = ({
const document_ = documentReference as DocumentWithViewTransition; const document_ = documentReference as DocumentWithViewTransition;
const prefersReducedMotion = hasReducedMotionPreference(); const prefersReducedMotion = hasReducedMotionPreference();
const canUseViewTransition = const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion;
Boolean(document_.startViewTransition) && !prefersReducedMotion;
if (canUseViewTransition) { if (canUseViewTransition) {
let xPercent = 0.5; let xPercent = 0.5;
@ -71,11 +70,7 @@ export const startThemeTransition = ({
yPercent = clamp01(context.pointerClientY / window.innerHeight); yPercent = clamp01(context.pointerClientY / window.innerHeight);
} else if (context?.element) { } else if (context?.element) {
const rect = context.element.getBoundingClientRect(); const rect = context.element.getBoundingClientRect();
if ( if (rect.width > 0 && rect.height > 0 && typeof window !== "undefined") {
rect.width > 0 &&
rect.height > 0 &&
typeof window !== "undefined"
) {
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth); xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight); 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") { if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark"; return "dark";
} }
return window.matchMedia("(prefers-color-scheme: dark)").matches return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
? "dark"
: "light";
} }
export function resolveTheme(mode: ThemeMode): ResolvedTheme { export function resolveTheme(mode: ThemeMode): ResolvedTheme {

View File

@ -90,7 +90,13 @@
}, },
"act": { "act": {
"label": "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"] }, "approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] }, "reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] }, "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_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": { "screen_record": {
"label": "screen record", "label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]

View File

@ -153,8 +153,7 @@ export function resolveToolDisplay(params: {
detail = resolveWriteDetail(params.args); detail = resolveWriteDetail(params.args);
} }
const detailKeys = const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) { if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys); detail = resolveDetailFromKeys(params.args, detailKeys);
} }
@ -192,7 +191,5 @@ export function formatToolSummary(display: ToolDisplay): string {
function shortenHomeInString(input: string): string { function shortenHomeInString(input: string): string {
if (!input) return input; if (!input) return input;
return input return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~");
.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 HealthSnapshot = Record<string, unknown>;
export type LogLevel = export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type LogEntry = { export type LogEntry = {
raw: string; raw: string;

View File

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

View File

@ -37,18 +37,22 @@ export function renderDiscordCard(params: {
</div> </div>
</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} ${discord.lastError}
</div>` </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"} · Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""} ${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing
}
${renderChannelConfigSection({ channelId: "discord", props })} ${renderChannelConfigSection({ channelId: "discord", props })}

View File

@ -34,9 +34,11 @@ export function renderGoogleChatCard(params: {
<div> <div>
<span class="label">Audience</span> <span class="label">Audience</span>
<span> <span>
${googleChat?.audienceType ${
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` googleChat?.audienceType
: "n/a"} ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"
}
</span> </span>
</div> </div>
<div> <div>
@ -49,18 +51,22 @@ export function renderGoogleChatCard(params: {
</div> </div>
</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} ${googleChat.lastError}
</div>` </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"} · Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing
}
${renderChannelConfigSection({ channelId: "googlechat", props })} ${renderChannelConfigSection({ channelId: "googlechat", props })}

View File

@ -37,18 +37,22 @@ export function renderIMessageCard(params: {
</div> </div>
</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} ${imessage.lastError}
</div>` </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"} · Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""} ${imessage.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing
}
${renderChannelConfigSection({ channelId: "imessage", props })} ${renderChannelConfigSection({ channelId: "imessage", props })}

View File

@ -82,7 +82,7 @@ export function renderNostrProfileForm(params: {
placeholder?: string; placeholder?: string;
maxLength?: number; maxLength?: number;
help?: string; help?: string;
} = {} } = {},
) => { ) => {
const { type = "text", placeholder, maxLength, help } = opts; const { type = "text", placeholder, maxLength, help } = opts;
const value = state.values[field] ?? ""; 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 style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
</div> </div>
${state.error ${
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>` state.error
: nothing} ? 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>` state.success
: nothing} ? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing
}
${renderPicturePreview()} ${renderPicturePreview()}
@ -204,8 +208,9 @@ export function renderNostrProfileForm(params: {
help: "HTTPS URL to your profile picture", 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="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> <div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
@ -232,7 +237,8 @@ export function renderNostrProfileForm(params: {
})} })}
</div> </div>
` `
: nothing} : nothing
}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;"> <div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button <button
@ -267,11 +273,15 @@ export function renderNostrProfileForm(params: {
</button> </button>
</div> </div>
${isDirty ${
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;"> isDirty
You have unsaved changes ? html`
</div>` <div style="font-size: 12px; color: var(--warning-color); margin-top: 8px">
: nothing} You have unsaved changes
</div>
`
: nothing
}
</div> </div>
`; `;
} }
@ -284,7 +294,7 @@ export function renderNostrProfileForm(params: {
* Create initial form state from existing profile * Create initial form state from existing profile
*/ */
export function createNostrProfileFormState( export function createNostrProfileFormState(
profile: NostrProfileType | undefined profile: NostrProfileType | undefined,
): NostrProfileFormState { ): NostrProfileFormState {
const values: NostrProfileType = { const values: NostrProfileType = {
name: profile?.name ?? "", name: profile?.name ?? "",
@ -305,8 +315,6 @@ export function createNostrProfileFormState(
error: null, error: null,
success: null, success: null,
fieldErrors: {}, fieldErrors: {},
showAdvanced: Boolean( showAdvanced: Boolean(profile?.banner || profile?.website || profile?.nip05 || profile?.lud16),
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 summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false; const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
const summaryPublicKey = const summaryPublicKey =
nostr?.publicKey ?? nostr?.publicKey ?? (primaryAccount as { publicKey?: string } | undefined)?.publicKey;
(primaryAccount as { publicKey?: string } | undefined)?.publicKey;
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null; const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null; const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1; const hasMultipleAccounts = nostrAccounts.length > 1;
@ -79,11 +78,13 @@ export function renderNostrCard(params: {
<span class="label">Last inbound</span> <span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span> <span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div> </div>
${account.lastError ${
? html` account.lastError
? html`
<div class="account-card-error">${account.lastError}</div> <div class="account-card-error">${account.lastError}</div>
` `
: nothing} : nothing
}
</div> </div>
</div> </div>
`; `;
@ -100,17 +101,19 @@ export function renderNostrCard(params: {
} }
const profile = const profile =
(primaryAccount as (
| { primaryAccount as
profile?: { | {
name?: string; profile?: {
displayName?: string; name?: string;
about?: string; displayName?: string;
picture?: string; about?: string;
nip05?: string; picture?: string;
}; nip05?: string;
} };
| undefined)?.profile ?? nostr?.profile; }
| undefined
)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {}; const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05; 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="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="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div> <div style="font-weight: 500;">Profile</div>
${summaryConfigured ${
? html` summaryConfigured
? html`
<button <button
class="btn btn-sm" class="btn btn-sm"
@click=${onEditProfile} @click=${onEditProfile}
@ -128,13 +132,16 @@ export function renderNostrCard(params: {
Edit Profile Edit Profile
</button> </button>
` `
: nothing} : nothing
}
</div> </div>
${hasAnyProfileData ${
? html` hasAnyProfileData
? html`
<div class="status-list"> <div class="status-list">
${picture ${
? html` picture
? html`
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
<img <img
src=${picture} src=${picture}
@ -146,22 +153,28 @@ export function renderNostrCard(params: {
/> />
</div> </div>
` `
: nothing} : nothing
}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : 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>` displayName
: nothing} ? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
${about : nothing
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</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} ${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div> </div>
` `
: html` : html`
<div style="color: var(--text-muted); font-size: 13px;"> <div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar. No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div> </div>
`} `
}
</div> </div>
`; `;
}; };
@ -172,13 +185,14 @@ export function renderNostrCard(params: {
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div> <div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel} ${accountCountLabel}
${hasMultipleAccounts ${
? html` hasMultipleAccounts
? html`
<div class="account-card-list"> <div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))} ${nostrAccounts.map((account) => renderAccountCard(account))}
</div> </div>
` `
: html` : html`
<div class="status-list" style="margin-top: 16px;"> <div class="status-list" style="margin-top: 16px;">
<div> <div>
<span class="label">Configured</span> <span class="label">Configured</span>
@ -199,11 +213,14 @@ export function renderNostrCard(params: {
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span> <span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div> </div>
</div> </div>
`} `
}
${summaryLastError ${
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>` summaryLastError
: nothing} ? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing
}
${renderProfileSection()} ${renderProfileSection()}

View File

@ -41,18 +41,22 @@ export function renderSignalCard(params: {
</div> </div>
</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} ${signal.lastError}
</div>` </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"} · Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""} ${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing
}
${renderChannelConfigSection({ channelId: "signal", props })} ${renderChannelConfigSection({ channelId: "signal", props })}

View File

@ -37,18 +37,22 @@ export function renderSlackCard(params: {
</div> </div>
</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} ${slack.lastError}
</div>` </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"} · Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""} ${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>` </div>`
: nothing} : nothing
}
${renderChannelConfigSection({ channelId: "slack", props })} ${renderChannelConfigSection({ channelId: "slack", props })}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,11 +28,69 @@ function jsonValue(value: unknown): string {
// SVG Icons as template literals // SVG Icons as template literals
const icons = { 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>`, chevronDown: html`
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>`, <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>`, viewBox="0 0 24 24"
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>`, fill="none"
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>`, 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: { export function renderNode(params: {
@ -64,7 +122,7 @@ export function renderNode(params: {
if (schema.anyOf || schema.oneOf) { if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? []; const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter( 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) { if (nonNull.length === 1) {
@ -88,16 +146,18 @@ export function renderNode(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented"> <div class="cfg-segmented">
${literals.map((lit, idx) => html` ${literals.map(
(lit) => html`
<button <button
type="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} ?disabled=${disabled}
@click=${() => onPatch(path, lit)} @click=${() => onPatch(path, lit)}
> >
${String(lit)} ${String(lit)}
</button> </button>
`)} `,
)}
</div> </div>
</div> </div>
`; `;
@ -109,11 +169,9 @@ export function renderNode(params: {
} }
// Handle mixed primitive types // Handle mixed primitive types
const primitiveTypes = new Set( const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
nonNull.map((variant) => schemaType(variant)).filter(Boolean)
);
const normalizedTypes = new Set( 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))) { 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} ${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented"> <div class="cfg-segmented">
${options.map((opt) => html` ${options.map(
(opt) => html`
<button <button
type="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} ?disabled=${disabled}
@click=${() => onPatch(path, opt)} @click=${() => onPatch(path, opt)}
> >
${String(opt)} ${String(opt)}
</button> </button>
`)} `,
)}
</div> </div>
</div> </div>
`; `;
@ -176,9 +236,14 @@ export function renderNode(params: {
// Boolean - toggle row // Boolean - toggle row
if (type === "boolean") { 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` return html`
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}"> <label class="cfg-toggle-row ${disabled ? "disabled" : ""}">
<div class="cfg-toggle-row__content"> <div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span> <span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing} ${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
@ -261,7 +326,9 @@ function renderTextInput(params: {
onPatch(path, raw); onPatch(path, raw);
}} }}
/> />
${schema.default !== undefined ? html` ${
schema.default !== undefined
? html`
<button <button
type="button" type="button"
class="cfg-input__reset" class="cfg-input__reset"
@ -269,7 +336,9 @@ function renderTextInput(params: {
?disabled=${disabled} ?disabled=${disabled}
@click=${() => onPatch(path, schema.default)} @click=${() => onPatch(path, schema.default)}
></button> ></button>
` : nothing} `
: nothing
}
</div> </div>
</div> </div>
`; `;
@ -360,9 +429,11 @@ function renderSelect(params: {
}} }}
> >
<option value=${unset}>Select...</option> <option value=${unset}>Select...</option>
${options.map((opt, idx) => html` ${options.map(
(opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option> <option value=${String(idx)}>${String(opt)}</option>
`)} `,
)}
</select> </select>
</div> </div>
`; `;
@ -379,15 +450,16 @@ function renderObject(params: {
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params; 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 hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description; const help = hint?.help ?? schema.description;
const fallback = value ?? schema.default; const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback) const obj =
? (fallback as Record<string, unknown>) fallback && typeof fallback === "object" && !Array.isArray(fallback)
: {}; ? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {}; const props = schema.properties ?? {};
const entries = Object.entries(props); const entries = Object.entries(props);
@ -416,18 +488,22 @@ function renderObject(params: {
unsupported, unsupported,
disabled, disabled,
onPatch, onPatch,
}) }),
)} )}
${allowExtra ? renderMapField({ ${
schema: additional as JsonSchema, allowExtra
value: obj, ? renderMapField({
path, schema: additional as JsonSchema,
hints, value: obj,
unsupported, path,
disabled, hints,
reservedKeys: reserved, unsupported,
onPatch, disabled,
}) : nothing} reservedKeys: reserved,
onPatch,
})
: nothing
}
</div> </div>
`; `;
} }
@ -450,18 +526,22 @@ function renderObject(params: {
unsupported, unsupported,
disabled, disabled,
onPatch, onPatch,
}) }),
)} )}
${allowExtra ? renderMapField({ ${
schema: additional as JsonSchema, allowExtra
value: obj, ? renderMapField({
path, schema: additional as JsonSchema,
hints, value: obj,
unsupported, path,
disabled, hints,
reservedKeys: reserved, unsupported,
onPatch, disabled,
}) : nothing} reservedKeys: reserved,
onPatch,
})
: nothing
}
</div> </div>
</details> </details>
`; `;
@ -499,7 +579,7 @@ function renderArray(params: {
<div class="cfg-array"> <div class="cfg-array">
<div class="cfg-array__header"> <div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing} ${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 <button
type="button" type="button"
class="cfg-array__add" class="cfg-array__add"
@ -515,13 +595,15 @@ function renderArray(params: {
</div> </div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html` ${
<div class="cfg-array__empty"> arr.length === 0
No items yet. Click "Add" to create one. ? html`
</div> <div class="cfg-array__empty">No items yet. Click "Add" to create one.</div>
` : html` `
: html`
<div class="cfg-array__items"> <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">
<div class="cfg-array__item-header"> <div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span> <span class="cfg-array__item-index">#${idx + 1}</span>
@ -552,9 +634,11 @@ function renderArray(params: {
})} })}
</div> </div>
</div> </div>
`)} `,
)}
</div> </div>
`} `
}
</div> </div>
`; `;
} }
@ -582,7 +666,7 @@ function renderMapField(params: {
class="cfg-map__add" class="cfg-map__add"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = { ...(value ?? {}) }; const next = { ...value };
let index = 1; let index = 1;
let key = `custom-${index}`; let key = `custom-${index}`;
while (key in next) { while (key in next) {
@ -598,9 +682,12 @@ function renderMapField(params: {
</button> </button>
</div> </div>
${entries.length === 0 ? html` ${
<div class="cfg-map__empty">No custom entries.</div> entries.length === 0
` : html` ? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items"> <div class="cfg-map__items">
${entries.map(([key, entryValue]) => { ${entries.map(([key, entryValue]) => {
const valuePath = [...path, key]; const valuePath = [...path, key];
@ -617,7 +704,7 @@ function renderMapField(params: {
@change=${(e: Event) => { @change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim(); const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) return; if (!nextKey || nextKey === key) return;
const next = { ...(value ?? {}) }; const next = { ...value };
if (nextKey in next) return; if (nextKey in next) return;
next[nextKey] = next[key]; next[nextKey] = next[key];
delete next[key]; delete next[key];
@ -626,8 +713,9 @@ function renderMapField(params: {
/> />
</div> </div>
<div class="cfg-map__item-value"> <div class="cfg-map__item-value">
${anySchema ${
? html` anySchema
? html`
<textarea <textarea
class="cfg-textarea cfg-textarea--sm" class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value" placeholder="JSON value"
@ -649,16 +737,17 @@ function renderMapField(params: {
}} }}
></textarea> ></textarea>
` `
: renderNode({ : renderNode({
schema, schema,
value: entryValue, value: entryValue,
path: valuePath, path: valuePath,
hints, hints,
unsupported, unsupported,
disabled, disabled,
showLabel: false, showLabel: false,
onPatch, onPatch,
})} })
}
</div> </div>
<button <button
type="button" type="button"
@ -666,7 +755,7 @@ function renderMapField(params: {
title="Remove entry" title="Remove entry"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => { @click=${() => {
const next = { ...(value ?? {}) }; const next = { ...value };
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);
}} }}
@ -677,7 +766,8 @@ function renderMapField(params: {
`; `;
})} })}
</div> </div>
`} `
}
</div> </div>
`; `;
} }

View File

@ -1,12 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; 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"; import { renderNode } from "./config-form.node";
export type ConfigFormProps = { export type ConfigFormProps = {
@ -23,44 +17,237 @@ export type ConfigFormProps = {
// SVG Icons for section cards (Lucide-style) // SVG Icons for section cards (Lucide-style)
const sectionIcons = { 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>`, env: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
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>`, <circle cx="12" cy="12" r="3"></circle>
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>`, <path
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>`, 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"
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>`, ></path>
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>`, </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>`, update: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
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>`, <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
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>`, <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 // 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>`, meta: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
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>`, <path d="M12 20h9"></path>
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>`, <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
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>`, </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>`, logging: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
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>`, <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
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>`, <polyline points="14 2 14 8 20 8"></polyline>
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>`, <line x1="16" y1="13" x2="8" y2="13"></line>
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>`, <line x1="16" y1="17" x2="8" y2="17"></line>
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>`, <polyline points="10 9 9 9 8 9"></polyline>
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>`, </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>`, 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 // Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = { 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" }, update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" }, agents: { label: "Agents", description: "Agent configurations, models, and identities" },
auth: { label: "Authentication", description: "API keys and authentication profiles" }, 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" }, messages: { label: "Messages", description: "Message handling and routing settings" },
commands: { label: "Commands", description: "Custom slash commands" }, commands: { label: "Commands", description: "Custom slash commands" },
hooks: { label: "Hooks", description: "Webhooks and event hooks" }, hooks: { label: "Hooks", description: "Webhooks and event hooks" },
@ -142,12 +329,16 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
export function renderConfigForm(props: ConfigFormProps) { export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) { if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`; return html`
<div class="muted">Schema unavailable.</div>
`;
} }
const schema = props.schema; const schema = props.schema;
const value = props.value ?? {}; const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) { 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 unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties; const properties = schema.properties;
@ -168,9 +359,8 @@ export function renderConfigForm(props: ConfigFormProps) {
return true; return true;
}); });
let subsectionContext: let subsectionContext: { sectionKey: string; subsectionKey: string; schema: JsonSchema } | null =
| { sectionKey: string; subsectionKey: string; schema: JsonSchema } null;
| null = null;
if (activeSection && activeSubsection && filteredEntries.length === 1) { if (activeSection && activeSubsection && filteredEntries.length === 1) {
const sectionSchema = filteredEntries[0]?.[1]; const sectionSchema = filteredEntries[0]?.[1];
if ( if (
@ -190,11 +380,9 @@ export function renderConfigForm(props: ConfigFormProps) {
if (filteredEntries.length === 0) { if (filteredEntries.length === 0) {
return html` return html`
<div class="config-empty"> <div class="config-empty">
<div class="config-empty__icon">${icons.search}</div> <div class="config-empty__icon">🔍</div>
<div class="config-empty__text"> <div class="config-empty__text">
${searchQuery ${searchQuery ? `No settings match "${searchQuery}"` : "No settings in this section"}
? `No settings match "${searchQuery}"`
: "No settings in this section"}
</div> </div>
</div> </div>
`; `;
@ -202,27 +390,30 @@ export function renderConfigForm(props: ConfigFormProps) {
return html` return html`
<div class="config-form config-form--modern"> <div class="config-form config-form--modern">
${subsectionContext ${
? (() => { subsectionContext
const { sectionKey, subsectionKey, schema: node } = subsectionContext; ? (() => {
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const label = hint?.label ?? node.title ?? humanize(subsectionKey); const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const description = hint?.help ?? node.description ?? ""; const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const sectionValue = (value as Record<string, unknown>)[sectionKey]; const description = hint?.help ?? node.description ?? "";
const scopedValue = const sectionValue = (value as Record<string, unknown>)[sectionKey];
sectionValue && typeof sectionValue === "object" const scopedValue =
? (sectionValue as Record<string, unknown>)[subsectionKey] sectionValue && typeof sectionValue === "object"
: undefined; ? (sectionValue as Record<string, unknown>)[subsectionKey]
const id = `config-section-${sectionKey}-${subsectionKey}`; : undefined;
return html` const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}> <section class="config-section-card" id=${id}>
<div class="config-section-card__header"> <div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span> <span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles"> <div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3> <h3 class="config-section-card__title">${label}</h3>
${description ${
? html`<p class="config-section-card__desc">${description}</p>` description
: nothing} ? html`<p class="config-section-card__desc">${description}</p>`
: nothing
}
</div> </div>
</div> </div>
<div class="config-section-card__content"> <div class="config-section-card__content">
@ -239,22 +430,24 @@ export function renderConfigForm(props: ConfigFormProps) {
</div> </div>
</section> </section>
`; `;
})() })()
: filteredEntries.map(([key, node]) => { : filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? { const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1), label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "", description: node.description ?? "",
}; };
return html` return html`
<section class="config-section-card" id="config-section-${key}"> <section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header"> <div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span> <span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles"> <div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3> <h3 class="config-section-card__title">${meta.label}</h3>
${meta.description ${
? html`<p class="config-section-card__desc">${meta.description}</p>` meta.description
: nothing} ? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing
}
</div> </div>
</div> </div>
<div class="config-section-card__content"> <div class="config-section-card__content">
@ -271,7 +464,8 @@ export function renderConfigForm(props: ConfigFormProps) {
</div> </div>
</section> </section>
`; `;
})} })
}
</div> </div>
`; `;
} }

View File

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

View File

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

View File

@ -1,12 +1,7 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared";
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
export type ConfigProps = { export type ConfigProps = {
raw: string; raw: string;
@ -41,36 +36,230 @@ export type ConfigProps = {
// SVG Icons for sidebar (Lucide-style) // SVG Icons for sidebar (Lucide-style)
const sidebarIcons = { 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>`, all: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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>`, <rect x="3" y="3" width="7" height="7"></rect>
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>`, <rect x="14" y="3" width="7" height="7"></rect>
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>`, <rect x="14" y="14" width="7" height="7"></rect>
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>`, <rect x="3" y="14" width="7" height="7"></rect>
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>`, </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>`, env: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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>`, <circle cx="12" cy="12" r="3"></circle>
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>`, <path
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>`, 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 // 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>`, meta: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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>`, <path d="M12 20h9"></path>
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>`, <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
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>`, </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>`, logging: html`
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>`, <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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>`, <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
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>`, <polyline points="14 2 14 8 20 8"></polyline>
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>`, <line x1="16" y1="13" x2="8" y2="13"></line>
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>`, <line x1="16" y1="17" x2="8" y2="17"></line>
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>`, <polyline points="10 9 9 9 8 9"></polyline>
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>`, </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>`, 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 // Section definitions
@ -102,7 +291,10 @@ function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
} }
function resolveSectionMeta(key: string, schema?: JsonSchema): { function resolveSectionMeta(
key: string,
schema?: JsonSchema,
): {
label: string; label: string;
description?: string; description?: string;
} { } {
@ -134,7 +326,7 @@ function resolveSubsections(params: {
function computeDiff( function computeDiff(
original: Record<string, unknown> | null, original: Record<string, unknown> | null,
current: Record<string, unknown> | null current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> { ): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) return []; if (!original || !current) return [];
const changes: Array<{ path: string; from: unknown; to: unknown }> = []; 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) { export function renderConfig(props: ConfigProps) {
const validity = const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema); const analysis = analyzeConfigSchema(props.schema);
const formUnsafe = analysis.schema const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
? analysis.unsupportedPaths.length > 0
: false;
// Get available sections from schema // Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {}; 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 // 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) const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k)) .filter((k) => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); .map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections]; const allSections = [...availableSections, ...extraSections];
@ -216,27 +405,22 @@ export function renderConfig(props: ConfigProps) {
}) })
: []; : [];
const allowSubnav = const allowSubnav =
props.formMode === "form" && props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0;
Boolean(props.activeSection) &&
subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION; const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery const effectiveSubsection = props.searchQuery
? null ? null
: isAllSubsection : isAllSubsection
? null ? 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) // Compute diff for showing changes (works for both form and raw modes)
const diff = props.formMode === "form" const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
? computeDiff(props.originalValue, props.formValue)
: [];
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw; const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges; const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled. // Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving. // Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm = const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave = const canSave =
props.connected && props.connected &&
!props.saving && !props.saving &&
@ -272,12 +456,16 @@ export function renderConfig(props: ConfigProps) {
.value=${props.searchQuery} .value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)} @input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/> />
${props.searchQuery ? html` ${
props.searchQuery
? html`
<button <button
class="config-search__clear" class="config-search__clear"
@click=${() => props.onSearchChange("")} @click=${() => props.onSearchChange("")}
>×</button> >×</button>
` : nothing} `
: nothing
}
</div> </div>
<!-- Section nav --> <!-- Section nav -->
@ -289,7 +477,8 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${sidebarIcons.all}</span> <span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span> <span class="config-nav__label">All Settings</span>
</button> </button>
${allSections.map(section => html` ${allSections.map(
(section) => html`
<button <button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}" class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)} @click=${() => props.onSectionChange(section.key)}
@ -297,7 +486,8 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${getSectionIcon(section.key)}</span> <span class="config-nav__icon">${getSectionIcon(section.key)}</span>
<span class="config-nav__label">${section.label}</span> <span class="config-nav__label">${section.label}</span>
</button> </button>
`)} `,
)}
</nav> </nav>
<!-- Mode toggle at bottom --> <!-- Mode toggle at bottom -->
@ -325,11 +515,15 @@ export function renderConfig(props: ConfigProps) {
<!-- Action bar --> <!-- Action bar -->
<div class="config-actions"> <div class="config-actions">
<div class="config-actions__left"> <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> <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>
<div class="config-actions__right"> <div class="config-actions__right">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}> <button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
@ -360,7 +554,9 @@ export function renderConfig(props: ConfigProps) {
</div> </div>
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) --> <!-- 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"> <details class="config-diff">
<summary class="config-diff__summary"> <summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span> <span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
@ -369,7 +565,8 @@ export function renderConfig(props: ConfigProps) {
</svg> </svg>
</summary> </summary>
<div class="config-diff__content"> <div class="config-diff__content">
${diff.map(change => html` ${diff.map(
(change) => html`
<div class="config-diff__item"> <div class="config-diff__item">
<div class="config-diff__path">${change.path}</div> <div class="config-diff__path">${change.path}</div>
<div class="config-diff__values"> <div class="config-diff__values">
@ -378,27 +575,35 @@ export function renderConfig(props: ConfigProps) {
<span class="config-diff__to">${truncateValue(change.to)}</span> <span class="config-diff__to">${truncateValue(change.to)}</span>
</div> </div>
</div> </div>
`)} `,
)}
</div> </div>
</details> </details>
` : nothing} `
: nothing
}
${activeSectionMeta && props.formMode === "form" ${
? html` activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero"> <div class="config-section-hero">
<div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div> <div class="config-section-hero__icon">${getSectionIcon(props.activeSection ?? "")}</div>
<div class="config-section-hero__text"> <div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div> <div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description ${
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>` activeSectionMeta.description
: nothing} ? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing
}
</div> </div>
</div> </div>
` `
: nothing} : nothing
}
${allowSubnav ${
? html` allowSubnav
? html`
<div class="config-subnav"> <div class="config-subnav">
<button <button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}" class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@ -421,36 +626,45 @@ export function renderConfig(props: ConfigProps) {
)} )}
</div> </div>
` `
: nothing} : nothing
}
<!-- Form content --> <!-- Form content -->
<div class="config-content"> <div class="config-content">
${props.formMode === "form" ${
? html` props.formMode === "form"
${props.schemaLoading ? html`
? html`<div class="config-loading"> ${
<div class="config-loading__spinner"></div> props.schemaLoading
<span>Loading schema</span> ? html`
</div>` <div class="config-loading">
: renderConfigForm({ <div class="config-loading__spinner"></div>
schema: analysis.schema, <span>Loading schema</span>
uiHints: props.uiHints, </div>
value: props.formValue, `
disabled: props.loading || !props.formValue, : renderConfigForm({
unsupportedPaths: analysis.unsupportedPaths, schema: analysis.schema,
onPatch: props.onFormPatch, uiHints: props.uiHints,
searchQuery: props.searchQuery, value: props.formValue,
activeSection: props.activeSection, disabled: props.loading || !props.formValue,
activeSubsection: effectiveSubsection, unsupportedPaths: analysis.unsupportedPaths,
})} onPatch: props.onFormPatch,
${formUnsafe searchQuery: props.searchQuery,
? html`<div class="callout danger" style="margin-top: 12px;"> activeSection: props.activeSection,
Form view can't safely edit some fields. activeSubsection: effectiveSubsection,
Use Raw to avoid losing config entries. })
</div>` }
: nothing} ${
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"> <label class="field config-raw-field">
<span>Raw JSON5</span> <span>Raw JSON5</span>
<textarea <textarea
@ -459,14 +673,17 @@ export function renderConfig(props: ConfigProps) {
props.onRawChange((e.target as HTMLTextAreaElement).value)} props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea> ></textarea>
</label> </label>
`} `
}
</div> </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> <pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>` </div>`
: nothing} : nothing
}
</main> </main>
</div> </div>
`; `;

View File

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

View File

@ -57,8 +57,7 @@ export function renderDebug(props: DebugProps) {
<span>Method</span> <span>Method</span>
<input <input
.value=${props.callMethod} .value=${props.callMethod}
@input=${(e: Event) => @input=${(e: Event) => props.onCallMethodChange((e.target as HTMLInputElement).value)}
props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence" placeholder="system-presence"
/> />
</label> </label>
@ -75,14 +74,18 @@ export function renderDebug(props: DebugProps) {
<div class="row" style="margin-top: 12px;"> <div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button> <button class="btn primary" @click=${props.onCall}>Call</button>
</div> </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} ${props.callError}
</div>` </div>`
: nothing} : nothing
${props.callResult }
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>` ${
: nothing} props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing
}
</div> </div>
</section> </section>
@ -99,9 +102,12 @@ export function renderDebug(props: DebugProps) {
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div> <div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</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>` props.eventLog.length === 0
: html` ? html`
<div class="muted" style="margin-top: 12px">No events yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;"> <div class="list" style="margin-top: 12px;">
${props.eventLog.map( ${props.eventLog.map(
(evt) => html` (evt) => html`
@ -117,7 +123,8 @@ export function renderDebug(props: DebugProps) {
`, `,
)} )}
</div> </div>
`} `
}
</section> </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-title">Exec approval needed</div>
<div class="exec-approval-sub">${remaining}</div> <div class="exec-approval-sub">${remaining}</div>
</div> </div>
${queueCount > 1 ${
? html`<div class="exec-approval-queue">${queueCount} pending</div>` queueCount > 1
: nothing} ? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing
}
</div> </div>
<div class="exec-approval-command mono">${request.command}</div> <div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta"> <div class="exec-approval-meta">
@ -46,9 +48,11 @@ export function renderExecApprovalPrompt(state: AppViewState) {
${renderMetaRow("Security", request.security)} ${renderMetaRow("Security", request.security)}
${renderMetaRow("Ask", request.ask)} ${renderMetaRow("Ask", request.ask)}
</div> </div>
${state.execApprovalError ${
? html`<div class="exec-approval-error">${state.execApprovalError}</div>` state.execApprovalError
: nothing} ? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing
}
<div class="exec-approval-actions"> <div class="exec-approval-actions">
<button <button
class="btn primary" class="btn primary"

View File

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

View File

@ -60,7 +60,11 @@ export function renderLogs(props: LogsProps) {
<button <button
class="btn" class="btn"
?disabled=${filtered.length === 0} ?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)} @click=${() =>
props.onExport(
filtered.map((entry) => entry.raw),
exportLabel,
)}
> >
Export ${exportLabel} Export ${exportLabel}
</button> </button>
@ -72,8 +76,7 @@ export function renderLogs(props: LogsProps) {
<span>Filter</span> <span>Filter</span>
<input <input
.value=${props.filterText} .value=${props.filterText}
@input=${(e: Event) => @input=${(e: Event) => props.onFilterTextChange((e.target as HTMLInputElement).value)}
props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs" placeholder="Search logs"
/> />
</label> </label>
@ -104,23 +107,32 @@ export function renderLogs(props: LogsProps) {
)} )}
</div> </div>
${props.file ${
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>` props.file
: nothing} ? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
${props.truncated : nothing
? html`<div class="callout" style="margin-top: 10px;"> }
Log output truncated; showing latest chunk. ${
</div>` props.truncated
: nothing} ? html`
${props.error <div class="callout" style="margin-top: 10px">Log output truncated; showing latest chunk.</div>
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>` `
: nothing} : 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}> <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.length === 0
: filtered.map( ? html`
(entry) => html` <div class="muted" style="padding: 12px">No log entries.</div>
`
: filtered.map(
(entry) => html`
<div class="log-row"> <div class="log-row">
<div class="log-time mono">${formatTime(entry.time)}</div> <div class="log-time mono">${formatTime(entry.time)}</div>
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</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 class="log-message mono">${entry.message ?? entry.raw}</div>
</div> </div>
`, `,
)} )
}
</div> </div>
</section> </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 { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons"; import { icons } from "../icons";
@ -21,16 +21,20 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
</button> </button>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
${props.error ${
? html` props.error
? html`
<div class="callout danger">${props.error}</div> <div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;"> <button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text View Raw Text
</button> </button>
` `
: props.content : props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>` ? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`<div class="muted">No content available</div>`} : html`
<div class="muted">No content available</div>
`
}
</div> </div>
</div> </div>
`; `;

View File

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

View File

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

View File

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

View File

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