Add ez-assistant and kerberos service folders

This commit is contained in:
kelin
2026-02-11 14:56:03 -05:00
parent e4e8ae1b87
commit 9ccfb36923
4471 changed files with 746463 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Moltbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.ico" sizes="any" />
</head>
<body>
<moltbot-app></moltbot-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"name": "moltbot-control-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"@noble/ed25519": "3.0.0",
"dompurify": "^3.3.1",
"lit": "^3.3.2",
"marked": "^17.0.1",
"vite": "7.3.1"
},
"devDependencies": {
"@vitest/browser-playwright": "4.0.18",
"playwright": "^1.58.0",
"typescript": "^5.9.3",
"vitest": "4.0.18"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,2 @@
import "./styles.css";
import "./ui/app.ts";

View File

@@ -0,0 +1,5 @@
@import "./styles/base.css";
@import "./styles/layout.css";
@import "./styles/layout.mobile.css";
@import "./styles/components.css";
@import "./styles/config.css";

View File

@@ -0,0 +1,372 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
:root {
/* Background - Warmer dark with depth */
--bg: #12141a;
--bg-accent: #14161d;
--bg-elevated: #1a1d25;
--bg-hover: #262a35;
--bg-muted: #262a35;
/* Card / Surface - More contrast between levels */
--card: #181b22;
--card-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05);
--popover: #181b22;
--popover-foreground: #f4f4f5;
/* Panel */
--panel: #12141a;
--panel-strong: #1a1d25;
--panel-hover: #262a35;
--chrome: rgba(18, 20, 26, 0.95);
--chrome-strong: rgba(18, 20, 26, 0.98);
/* Text - Slightly warmer */
--text: #e4e4e7;
--text-strong: #fafafa;
--chat-text: #e4e4e7;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
/* Border - Subtle but defined */
--border: #27272a;
--border-strong: #3f3f46;
--border-hover: #52525b;
--input: #27272a;
--ring: #ff5c5c;
/* Accent - Punchy signature red */
--accent: #ff5c5c;
--accent-hover: #ff7070;
--accent-muted: #ff5c5c;
--accent-subtle: rgba(255, 92, 92, 0.15);
--accent-foreground: #fafafa;
--accent-glow: rgba(255, 92, 92, 0.25);
--primary: #ff5c5c;
--primary-foreground: #ffffff;
/* Secondary - Teal accent for variety */
--secondary: #1e2028;
--secondary-foreground: #f4f4f5;
--accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic - More saturated */
--ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: rgba(34, 197, 94, 0.12);
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--warn: #f59e0b;
--warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6;
/* Focus - With glow */
--focus: rgba(255, 92, 92, 0.25);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
/* Grid */
--grid-line: rgba(255, 255, 255, 0.04);
/* Theme transition */
--theme-switch-x: 50%;
--theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-glow: 0 0 30px var(--accent-glow);
/* Radii - Slightly larger for friendlier feel */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--radius: 8px;
/* Transitions - Snappy but smooth */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 350ms;
color-scheme: dark;
}
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] {
--bg: #fafafa;
--bg-accent: #f5f5f5;
--bg-elevated: #ffffff;
--bg-hover: #f0f0f0;
--bg-muted: #f0f0f0;
--bg-content: #f5f5f5;
--card: #ffffff;
--card-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.03);
--popover: #ffffff;
--popover-foreground: #18181b;
--panel: #fafafa;
--panel-strong: #f5f5f5;
--panel-hover: #ebebeb;
--chrome: rgba(250, 250, 250, 0.95);
--chrome-strong: rgba(250, 250, 250, 0.98);
--text: #3f3f46;
--text-strong: #18181b;
--chat-text: #3f3f46;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--border-hover: #a1a1aa;
--input: #e4e4e7;
--accent: #dc2626;
--accent-hover: #ef4444;
--accent-muted: #dc2626;
--accent-subtle: rgba(220, 38, 38, 0.12);
--accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15);
--primary: #dc2626;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #3f3f46;
--accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12);
--ok: #16a34a;
--ok-muted: rgba(22, 163, 74, 0.75);
--ok-subtle: rgba(22, 163, 74, 0.1);
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--warn: #d97706;
--warn-muted: rgba(217, 119, 6, 0.75);
--warn-subtle: rgba(217, 119, 6, 0.1);
--danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.1);
--info: #2563eb;
--focus: rgba(220, 38, 38, 0.2);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
--grid-line: rgba(0, 0, 0, 0.05);
/* Light shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-glow: 0 0 24px var(--accent-glow);
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font: 400 14px/1.55 var(--font-body);
letter-spacing: -0.02em;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Theme transition */
@keyframes theme-circle-transition {
0% {
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
100% {
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
}
}
html.theme-transition {
view-transition-name: theme;
}
html.theme-transition::view-transition-old(theme) {
mix-blend-mode: normal;
animation: none;
z-index: 1;
}
html.theme-transition::view-transition-new(theme) {
mix-blend-mode: normal;
z-index: 2;
animation: theme-circle-transition 0.4s var(--ease-out) forwards;
}
@media (prefers-reduced-motion: reduce) {
html.theme-transition::view-transition-old(theme),
html.theme-transition::view-transition-new(theme) {
animation: none !important;
}
}
moltbot-app {
display: block;
position: relative;
z-index: 1;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
textarea,
select {
font: 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 {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dashboard-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
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

@@ -0,0 +1,5 @@
@import "./chat/layout.css";
@import "./chat/text.css";
@import "./chat/grouped.css";
@import "./chat/tool-cards.css";
@import "./chat/sidebar.css";

View File

@@ -0,0 +1,268 @@
/* =============================================
GROUPED CHAT LAYOUT (Slack-style)
============================================= */
/* Chat Group Layout - default (assistant/other on left) */
.chat-group {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 16px;
margin-left: 4px;
margin-right: 16px;
}
/* User messages on right */
.chat-group.user {
flex-direction: row-reverse;
justify-content: flex-start;
}
.chat-group-messages {
display: flex;
flex-direction: column;
gap: 2px;
max-width: min(900px, calc(100% - 60px));
}
/* User messages align content right */
.chat-group.user .chat-group-messages {
align-items: flex-end;
}
.chat-group.user .chat-group-footer {
justify-content: flex-end;
}
/* Footer at bottom of message group (role + time) */
.chat-group-footer {
display: flex;
gap: 8px;
align-items: baseline;
margin-top: 6px;
}
.chat-sender-name {
font-weight: 500;
font-size: 12px;
color: var(--muted);
}
.chat-group-timestamp {
font-size: 11px;
color: var(--muted);
opacity: 0.7;
}
/* Avatar Styles */
.chat-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--panel-strong);
display: grid;
place-items: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
align-self: flex-end; /* Align with last message in group */
margin-bottom: 4px; /* Optical alignment */
}
.chat-avatar.user {
background: var(--accent-subtle);
color: var(--accent);
}
.chat-avatar.assistant {
background: var(--secondary);
color: var(--muted);
}
.chat-avatar.other {
background: var(--secondary);
color: var(--muted);
}
.chat-avatar.tool {
background: var(--secondary);
color: var(--muted);
}
/* Image avatar support */
img.chat-avatar {
display: block;
object-fit: cover;
object-position: center;
}
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
display: inline-block;
border: 1px solid transparent;
background: var(--card);
border-radius: var(--radius-lg);
padding: 10px 14px;
box-shadow: none;
transition: background 150ms ease-out, border-color 150ms ease-out;
max-width: 100%;
word-wrap: break-word;
}
.chat-bubble.has-copy {
padding-right: 36px;
}
.chat-copy-btn {
position: absolute;
top: 6px;
right: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
border-radius: var(--radius-md);
padding: 4px 6px;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, background 120ms ease-out;
}
.chat-copy-btn__icon {
display: inline-flex;
width: 14px;
height: 14px;
position: relative;
}
.chat-copy-btn__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-copy-btn__icon-copy,
.chat-copy-btn__icon-check {
position: absolute;
top: 0;
left: 0;
transition: opacity 150ms ease;
}
.chat-copy-btn__icon-check {
opacity: 0;
}
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy {
opacity: 0;
}
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check {
opacity: 1;
}
.chat-bubble:hover .chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
.chat-copy-btn:hover {
background: var(--bg-hover);
}
.chat-copy-btn[data-copying="1"] {
opacity: 0;
pointer-events: none;
}
.chat-copy-btn[data-error="1"] {
opacity: 1;
pointer-events: auto;
border-color: var(--danger-subtle);
background: var(--danger-subtle);
color: var(--danger);
}
.chat-copy-btn[data-copied="1"] {
opacity: 1;
pointer-events: auto;
border-color: var(--ok-subtle);
background: var(--ok-subtle);
color: var(--ok);
}
.chat-copy-btn:focus-visible {
opacity: 1;
pointer-events: auto;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (hover: none) {
.chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
}
/* Light mode: restore borders */
:root[data-theme="light"] .chat-bubble {
border-color: var(--border);
box-shadow: inset 0 1px 0 var(--card-highlight);
}
.chat-bubble:hover {
background: var(--bg-hover);
}
/* User bubbles have different styling */
.chat-group.user .chat-bubble {
background: var(--accent-subtle);
border-color: transparent;
}
:root[data-theme="light"] .chat-group.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2);
background: rgba(251, 146, 60, 0.12);
}
.chat-group.user .chat-bubble:hover {
background: rgba(255, 77, 77, 0.15);
}
/* Streaming animation */
.chat-bubble.streaming {
animation: pulsing-border 1.5s ease-out infinite;
}
@keyframes pulsing-border {
0%, 100% {
border-color: var(--border);
}
50% {
border-color: var(--accent);
}
}
/* Fade-in animation for new messages */
.chat-bubble.fade-in {
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,397 @@
/* =============================================
CHAT CARD LAYOUT - Flex container with sticky compose
============================================= */
/* Main chat card - flex column layout, transparent background */
.chat {
position: relative;
display: flex;
flex-direction: column;
flex: 1 1 0;
height: 100%;
min-height: 0; /* Allow flex shrinking */
overflow: hidden;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Chat header - fixed at top, transparent */
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
flex-shrink: 0;
padding-bottom: 12px;
margin-bottom: 12px;
background: transparent;
}
.chat-header__left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.chat-header__right {
display: flex;
align-items: center;
gap: 8px;
}
.chat-session {
min-width: 180px;
}
/* Chat thread - scrollable middle section, transparent */
.chat-thread {
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
padding: 12px 4px;
margin: 0 -4px;
min-height: 0; /* Allow shrinking for flex scroll behavior */
border-radius: 12px;
background: transparent;
}
/* Focus mode exit button */
.chat-focus-exit {
position: absolute;
top: 12px;
right: 12px;
z-index: 100;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
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);
}
.chat-focus-exit:hover {
background: var(--panel-strong);
color: var(--text);
border-color: var(--accent);
}
.chat-focus-exit svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Chat compose - sticky at bottom */
.chat-compose {
position: sticky;
bottom: 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px;
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
z-index: 10;
}
/* Image attachments preview */
.chat-attachments {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border: 1px solid var(--border);
width: fit-content;
max-width: 100%;
align-self: flex-start; /* Don't stretch in flex column parent */
}
.chat-attachment {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg);
}
.chat-attachment__img {
width: 100%;
height: 100%;
object-fit: contain;
}
.chat-attachment__remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease-out;
}
.chat-attachment:hover .chat-attachment__remove {
opacity: 1;
}
.chat-attachment__remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.chat-attachment__remove svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
}
/* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments {
background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1);
}
:root[data-theme="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15);
background: #fff;
}
:root[data-theme="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6);
}
/* Message images (sent images displayed in chat) */
.chat-message-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.chat-message-image {
max-width: 300px;
max-height: 200px;
border-radius: 8px;
object-fit: contain;
cursor: pointer;
transition: transform 150ms ease-out;
}
.chat-message-image:hover {
transform: scale(1.02);
}
/* User message images align right */
.chat-group.user .chat-message-images {
justify-content: flex-end;
}
/* Compose input row - horizontal layout */
.chat-compose__row {
display: flex;
align-items: stretch;
gap: 12px;
flex: 1;
}
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}
.chat-compose__field {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: stretch;
}
/* Hide the "Message" label - keep textarea only */
.chat-compose__field > span {
display: none;
}
/* Override .field textarea min-height (180px) from components.css */
.chat-compose .chat-compose__field textarea {
width: 100%;
height: 40px;
min-height: 40px;
max-height: 150px;
padding: 9px 12px;
border-radius: 8px;
overflow-y: auto;
resize: none;
white-space: pre-wrap;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
}
.chat-compose__field textarea:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.chat-compose__actions {
flex-shrink: 0;
display: flex;
align-items: stretch;
gap: 8px;
}
.chat-compose .chat-compose__actions .btn {
padding: 0 16px;
font-size: 13px;
height: 40px;
min-height: 40px;
max-height: 40px;
line-height: 1;
white-space: nowrap;
box-sizing: border-box;
}
/* Chat controls - moved to content-header area, left aligned */
.chat-controls {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.chat-controls__session {
min-width: 140px;
}
.chat-controls__thinking {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
/* Icon button style */
.btn--icon {
padding: 8px !important;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
}
/* Controls separator */
.chat-controls__separator {
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
margin: 0 8px;
font-weight: 300;
}
:root[data-theme="light"] .chat-controls__separator {
color: rgba(16, 24, 40, 0.3);
}
.btn--icon:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
/* Light theme icon button overrides */
:root[data-theme="light"] .btn--icon {
background: #ffffff;
border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted);
}
:root[data-theme="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
.btn--icon svg {
display: block;
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-controls__session select {
padding: 6px 10px;
font-size: 13px;
}
.chat-controls__thinking {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
border: 1px solid var(--border);
}
/* Light theme thinking indicator override */
:root[data-theme="light"] .chat-controls__thinking {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(16, 24, 40, 0.15);
}
@media (max-width: 640px) {
.chat-session {
min-width: 140px;
}
.chat-compose {
grid-template-columns: 1fr;
}
.chat-controls {
flex-wrap: wrap;
gap: 8px;
}
.chat-controls__session {
min-width: 120px;
}
}

View File

@@ -0,0 +1,117 @@
/* Split View Layout */
.chat-split-container {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
height: 100%;
}
.chat-main {
min-width: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
/* Smooth transition when sidebar opens/closes */
transition: flex 250ms ease-out;
}
.chat-sidebar {
flex: 1;
min-width: 300px;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slide-in 200ms ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Sidebar Panel */
.sidebar-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
background: var(--panel);
}
/* Smaller close button for sidebar */
.sidebar-header .btn {
padding: 4px 8px;
font-size: 14px;
min-width: auto;
line-height: 1;
}
.sidebar-title {
font-weight: 600;
font-size: 14px;
}
.sidebar-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.sidebar-markdown {
font-size: 14px;
line-height: 1.5;
}
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
padding: 12px;
overflow-x: auto;
}
.sidebar-markdown code {
font-family: var(--mono);
font-size: 13px;
}
/* Mobile: Full-screen modal */
@media (max-width: 768px) {
.chat-split-container--open {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.chat-split-container--open .chat-main {
display: none; /* Hide chat on mobile when sidebar open */
}
.chat-split-container--open .chat-sidebar {
width: 100%;
min-width: 0;
border-left: none;
}
}

View File

@@ -0,0 +1,124 @@
/* =============================================
CHAT TEXT STYLING
============================================= */
.chat-thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
:root[data-theme="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.25);
background: rgba(16, 24, 40, 0.04);
}
.chat-text {
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.chat-text :where(p, ul, ol, pre, blockquote, table) {
margin: 0;
}
.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) {
margin-top: 0.75em;
}
.chat-text :where(ul, ol) {
padding-left: 1.5em;
}
.chat-text :where(li + li) {
margin-top: 0.25em;
}
.chat-text :where(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.chat-text :where(a:hover) {
opacity: 0.8;
}
.chat-text :where(code) {
font-family: var(--mono);
font-size: 0.9em;
}
.chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.15);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.chat-text :where(pre) {
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
}
.chat-text :where(pre code) {
background: none;
padding: 0;
}
.chat-text :where(blockquote) {
border-left: 3px solid var(--border-strong);
padding-left: 12px;
margin-left: 0;
color: var(--muted);
background: rgba(255, 255, 255, 0.02);
padding: 8px 12px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.chat-text :where(blockquote blockquote) {
margin-top: 8px;
border-left-color: var(--border-hover);
background: rgba(255, 255, 255, 0.03);
}
.chat-text :where(blockquote blockquote blockquote) {
border-left-color: var(--muted-strong);
background: rgba(255, 255, 255, 0.04);
}
:root[data-theme="light"] .chat-text :where(blockquote) {
background: rgba(0, 0, 0, 0.03);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
background: rgba(0, 0, 0, 0.05);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
background: rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .chat-text :where(pre) {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.chat-text :where(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 1em 0;
}

View File

@@ -0,0 +1,198 @@
/* Tool Card Styles */
.chat-tool-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 8px;
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition: border-color 150ms ease-out, background 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */
max-height: 120px;
overflow: hidden;
}
.chat-tool-card:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
}
/* First tool card in a group - no top margin */
.chat-tool-card:first-child {
margin-top: 0;
}
.chat-tool-card--clickable {
cursor: pointer;
}
.chat-tool-card--clickable:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Header with title and chevron */
.chat-tool-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.chat-tool-card__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.chat-tool-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.chat-tool-card__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* "View >" action link */
.chat-tool-card__action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--accent);
opacity: 0.8;
transition: opacity 150ms ease-out;
}
.chat-tool-card__action svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card--clickable:hover .chat-tool-card__action {
opacity: 1;
}
/* Status indicator for completed/empty results */
.chat-tool-card__status {
display: inline-flex;
align-items: center;
color: var(--ok);
}
.chat-tool-card__status svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-card__status-text {
font-size: 11px;
margin-top: 4px;
}
.chat-tool-card__detail {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
/* Collapsed preview - fixed height with truncation */
.chat-tool-card__preview {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
padding: 8px 10px;
background: var(--secondary);
border-radius: var(--radius-md);
white-space: pre-wrap;
overflow: hidden;
max-height: 44px;
line-height: 1.4;
border: 1px solid var(--border);
}
.chat-tool-card--clickable:hover .chat-tool-card__preview {
background: var(--bg-hover);
border-color: var(--border-strong);
}
/* Short inline output */
.chat-tool-card__inline {
font-size: 11px;
color: var(--text);
margin-top: 6px;
padding: 6px 8px;
background: var(--secondary);
border-radius: var(--radius-sm);
white-space: pre-wrap;
word-break: break-word;
}
/* Reading Indicator */
.chat-reading-indicator {
background: transparent;
border: 1px solid var(--border);
padding: 12px;
display: inline-flex;
}
.chat-reading-indicator__dots {
display: flex;
gap: 6px;
align-items: center;
}
.chat-reading-indicator__dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
animation: reading-pulse 1.4s ease-in-out infinite;
}
.chat-reading-indicator__dots span:nth-child(1) {
animation-delay: 0s;
}
.chat-reading-indicator__dots span:nth-child(2) {
animation-delay: 0.2s;
}
.chat-reading-indicator__dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes reading-pulse {
0%, 60%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,609 @@
/* ===========================================
Shell Layout
=========================================== */
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 220px;
--shell-topbar-height: 56px;
--shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out);
height: 100vh;
display: grid;
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) 1fr;
grid-template-areas:
"topbar topbar"
"nav content";
gap: 0;
animation: dashboard-enter 0.4s var(--ease-out);
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
overflow: hidden;
}
@supports (height: 100dvh) {
.shell {
height: 100dvh;
}
}
.shell--chat {
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
@supports (height: 100dvh) {
.shell--chat {
height: 100dvh;
}
}
.shell--nav-collapsed {
grid-template-columns: 0px minmax(0, 1fr);
}
.shell--chat-focus {
grid-template-columns: 0px minmax(0, 1fr);
}
.shell--onboarding {
grid-template-rows: 0 1fr;
}
.shell--onboarding .topbar {
display: none;
}
.shell--onboarding .content {
padding-top: 0;
}
.shell--chat-focus .content {
padding-top: 0;
gap: 0;
}
/* ===========================================
Topbar
=========================================== */
.topbar {
grid-area: topbar;
position: sticky;
top: 0;
z-index: 40;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 0 20px;
height: var(--shell-topbar-height);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.topbar .nav-collapse-toggle {
width: 36px;
height: 36px;
margin-bottom: 0;
}
.topbar .nav-collapse-toggle__icon {
width: 20px;
height: 20px;
}
.topbar .nav-collapse-toggle__icon svg {
width: 20px;
height: 20px;
}
/* Brand */
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-strong);
}
.brand-sub {
font-size: 10px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
line-height: 1;
}
/* Topbar status */
.topbar-status {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-status .pill {
padding: 6px 10px;
gap: 6px;
font-size: 12px;
font-weight: 500;
height: 32px;
box-sizing: border-box;
}
.topbar-status .pill .mono {
display: flex;
align-items: center;
line-height: 1;
margin-top: 0px;
}
.topbar-status .statusDot {
width: 6px;
height: 6px;
}
.topbar-status .theme-toggle {
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
}
.topbar-status .theme-icon {
width: 12px;
height: 12px;
}
/* ===========================================
Navigation Sidebar
=========================================== */
.nav {
grid-area: nav;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 12px;
background: var(--bg);
scrollbar-width: none; /* Firefox */
transition:
width var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0;
}
.nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.shell--chat-focus .nav {
width: 0;
padding: 0;
border-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
}
.nav--collapsed {
width: 0;
min-width: 0;
padding: 0;
overflow: hidden;
border: none;
opacity: 0;
pointer-events: none;
}
/* Nav collapse toggle */
.nav-collapse-toggle {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
margin-bottom: 16px;
}
.nav-collapse-toggle:hover {
background: var(--bg-hover);
border-color: var(--border);
}
.nav-collapse-toggle__icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--muted);
transition: color var(--duration-fast) ease;
}
.nav-collapse-toggle__icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: var(--text);
}
/* Nav groups */
.nav-group {
margin-bottom: 20px;
display: grid;
gap: 2px;
}
.nav-group:last-child {
margin-bottom: 0;
}
.nav-group__items {
display: grid;
gap: 1px;
}
.nav-group--collapsed .nav-group__items {
display: none;
}
/* Nav label */
.nav-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 10px;
font-size: 11px;
font-weight: 500;
color: var(--muted);
margin-bottom: 4px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
border-radius: var(--radius-sm);
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.nav-label:hover {
color: var(--text);
background: var(--bg-hover);
}
.nav-label--static {
cursor: default;
}
.nav-label--static:hover {
color: var(--muted);
background: transparent;
}
.nav-label__text {
flex: 1;
}
.nav-label__chevron {
font-size: 10px;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
}
.nav-group--collapsed .nav-label__chevron {
transform: rotate(-90deg);
}
/* Nav items */
.nav-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
color: var(--muted);
cursor: pointer;
text-decoration: none;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.nav-item__icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.7;
transition: opacity var(--duration-fast) ease;
}
.nav-item__icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-item__text {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.nav-item:hover {
color: var(--text);
background: var(--bg-hover);
text-decoration: none;
}
.nav-item:hover .nav-item__icon {
opacity: 1;
}
.nav-item.active {
color: var(--text-strong);
background: var(--accent-subtle);
}
.nav-item.active .nav-item__icon {
opacity: 1;
color: var(--accent);
}
/* ===========================================
Content Area
=========================================== */
.content {
grid-area: content;
padding: 12px 16px 32px;
display: flex;
flex-direction: column;
gap: 24px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
:root[data-theme="light"] .content {
background: var(--bg-content);
}
.content--chat {
overflow: hidden;
padding-bottom: 0;
}
/* Content header */
.content-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 4px 8px;
overflow: hidden;
transform-origin: top center;
transition:
opacity 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),
padding var(--shell-focus-duration) var(--shell-focus-ease);
max-height: 80px;
}
.shell--chat-focus .content-header {
opacity: 0;
transform: translateY(-8px);
max-height: 0px;
padding: 0;
pointer-events: none;
}
.page-title {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.15;
color: var(--text-strong);
}
.page-sub {
color: var(--muted);
font-size: 14px;
font-weight: 400;
margin-top: 6px;
letter-spacing: -0.01em;
}
.page-meta {
display: flex;
gap: 8px;
}
/* Chat view header adjustments */
.content--chat .content-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.content--chat .content-header > div:first-child {
text-align: left;
}
.content--chat .page-meta {
justify-content: flex-start;
}
.content--chat .chat-controls {
flex-shrink: 0;
}
/* ===========================================
Grid Utilities
=========================================== */
.grid {
display: grid;
gap: 20px;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.note-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.row {
display: flex;
gap: 12px;
align-items: center;
}
.stack {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
/* ===========================================
Responsive - Tablet
=========================================== */
@media (max-width: 1100px) {
.shell {
--shell-pad: 12px;
--shell-gap: 12px;
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
"topbar"
"nav"
"content";
}
.nav {
position: static;
max-height: none;
display: flex;
gap: 6px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 10px 14px;
background: var(--bg);
}
.nav-group {
grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
margin-bottom: 0;
}
.grid-cols-2,
.grid-cols-3 {
grid-template-columns: 1fr;
}
.topbar {
position: static;
padding: 12px 14px;
gap: 10px;
}
.topbar-status {
flex-wrap: wrap;
}
.table-head,
.table-row {
grid-template-columns: 1fr;
}
.list-item {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,374 @@
/* ===========================================
Mobile Layout
=========================================== */
/* Tablet: Horizontal nav */
@media (max-width: 1100px) {
.nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
padding: 10px 14px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.nav::-webkit-scrollbar {
display: none;
}
.nav-group {
display: contents;
}
.nav-group__items {
display: contents;
}
.nav-label {
display: none;
}
.nav-group--collapsed .nav-group__items {
display: contents;
}
.nav-item {
padding: 8px 14px;
font-size: 13px;
border-radius: var(--radius-md);
white-space: nowrap;
flex-shrink: 0;
}
}
/* Mobile-specific styles */
@media (max-width: 600px) {
.shell {
--shell-pad: 8px;
--shell-gap: 8px;
}
/* Topbar */
.topbar {
padding: 10px 12px;
gap: 8px;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.brand {
flex: 1;
min-width: 0;
}
.brand-title {
font-size: 14px;
}
.brand-sub {
display: none;
}
.topbar-status {
gap: 6px;
width: auto;
flex-wrap: nowrap;
}
.topbar-status .pill {
padding: 4px 8px;
font-size: 11px;
gap: 4px;
}
.topbar-status .pill .mono {
display: none;
}
.topbar-status .pill span:nth-child(2) {
display: none;
}
/* Nav */
.nav {
padding: 8px 10px;
gap: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.nav::-webkit-scrollbar {
display: none;
}
.nav-group {
display: contents;
}
.nav-label {
display: none;
}
.nav-item {
padding: 6px 10px;
font-size: 12px;
border-radius: var(--radius-md);
white-space: nowrap;
flex-shrink: 0;
}
/* Content */
.content-header {
display: none;
}
.content {
padding: 4px 4px 16px;
gap: 12px;
}
/* Cards */
.card {
padding: 12px;
border-radius: var(--radius-md);
}
.card-title {
font-size: 13px;
}
/* Stats */
.stat-grid {
gap: 8px;
grid-template-columns: repeat(2, 1fr);
}
.stat {
padding: 10px;
border-radius: var(--radius-md);
}
.stat-label {
font-size: 11px;
}
.stat-value {
font-size: 18px;
}
/* Notes */
.note-grid {
grid-template-columns: 1fr;
gap: 8px;
}
/* Forms */
.form-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.field input,
.field textarea,
.field select {
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: 14px;
}
/* Buttons */
.btn {
padding: 8px 12px;
font-size: 12px;
}
/* Pills */
.pill {
padding: 4px 10px;
font-size: 12px;
}
/* Chat */
.chat-header {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.chat-header__left {
flex-direction: column;
align-items: stretch;
}
.chat-header__right {
justify-content: space-between;
}
.chat-session {
min-width: unset;
width: 100%;
}
.chat-thread {
margin-top: 8px;
padding: 12px 8px;
}
.chat-msg {
max-width: 90%;
}
.chat-bubble {
padding: 8px 12px;
border-radius: var(--radius-md);
}
.chat-compose {
gap: 8px;
}
.chat-compose__field textarea {
min-height: 60px;
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: 14px;
}
/* Log stream */
.log-stream {
border-radius: var(--radius-md);
max-height: 380px;
}
.log-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 8px;
}
.log-time {
font-size: 10px;
}
.log-level {
font-size: 9px;
}
.log-subsystem {
font-size: 11px;
}
.log-message {
font-size: 12px;
}
/* Lists */
.list-item {
padding: 10px;
border-radius: var(--radius-md);
}
.list-title {
font-size: 13px;
}
.list-sub {
font-size: 11px;
}
/* Code blocks */
.code-block {
padding: 8px;
border-radius: var(--radius-md);
font-size: 11px;
}
/* Theme toggle */
.theme-toggle {
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
}
.theme-icon {
width: 12px;
height: 12px;
}
}
/* Small mobile */
@media (max-width: 400px) {
.shell {
--shell-pad: 4px;
}
.topbar {
padding: 8px 10px;
}
.brand-title {
font-size: 13px;
}
.nav {
padding: 6px 8px;
}
.nav-item {
padding: 6px 8px;
font-size: 11px;
}
.content {
padding: 4px 4px 12px;
gap: 10px;
}
.card {
padding: 10px;
}
.stat {
padding: 8px;
}
.stat-value {
font-size: 16px;
}
.chat-bubble {
padding: 8px 10px;
}
.chat-compose__field textarea {
min-height: 52px;
padding: 8px 10px;
font-size: 13px;
}
.btn {
padding: 6px 10px;
font-size: 11px;
}
.topbar-status .pill {
padding: 3px 6px;
font-size: 10px;
}
.theme-toggle {
--theme-item: 22px;
--theme-gap: 2px;
--theme-pad: 2px;
}
.theme-icon {
width: 11px;
height: 11px;
}
}

View File

@@ -0,0 +1,233 @@
import {
loadChannels,
logoutWhatsApp,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/channels";
import { loadConfig, saveConfig } from "./controllers/config";
import type { MoltbotApp } from "./app";
import type { NostrProfile } from "./types";
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form";
export async function handleWhatsAppStart(host: MoltbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
await loadChannels(host, true);
}
export async function handleWhatsAppWait(host: MoltbotApp) {
await waitWhatsAppLogin(host);
await loadChannels(host, true);
}
export async function handleWhatsAppLogout(host: MoltbotApp) {
await logoutWhatsApp(host);
await loadChannels(host, true);
}
export async function handleChannelConfigSave(host: MoltbotApp) {
await saveConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleChannelConfigReload(host: MoltbotApp) {
await loadConfig(host);
await loadChannels(host, true);
}
function parseValidationErrors(details: unknown): Record<string, string> {
if (!Array.isArray(details)) return {};
const errors: Record<string, string> = {};
for (const entry of details) {
if (typeof entry !== "string") continue;
const [rawField, ...rest] = entry.split(":");
if (!rawField || rest.length === 0) continue;
const field = rawField.trim();
const message = rest.join(":").trim();
if (field && message) errors[field] = message;
}
return errors;
}
function resolveNostrAccountId(host: MoltbotApp): string {
const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? [];
return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default";
}
function buildNostrProfileUrl(accountId: string, suffix = ""): string {
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
}
export function handleNostrProfileEdit(
host: MoltbotApp,
accountId: string,
profile: NostrProfile | null,
) {
host.nostrProfileAccountId = accountId;
host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined);
}
export function handleNostrProfileCancel(host: MoltbotApp) {
host.nostrProfileFormState = null;
host.nostrProfileAccountId = null;
}
export function handleNostrProfileFieldChange(
host: MoltbotApp,
field: keyof NostrProfile,
value: string,
) {
const state = host.nostrProfileFormState;
if (!state) return;
host.nostrProfileFormState = {
...state,
values: {
...state.values,
[field]: value,
},
fieldErrors: {
...state.fieldErrors,
[field]: "",
},
};
}
export function handleNostrProfileToggleAdvanced(host: MoltbotApp) {
const state = host.nostrProfileFormState;
if (!state) return;
host.nostrProfileFormState = {
...state,
showAdvanced: !state.showAdvanced,
};
}
export async function handleNostrProfileSave(host: MoltbotApp) {
const state = host.nostrProfileFormState;
if (!state || state.saving) return;
const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = {
...state,
saving: true,
error: null,
success: null,
fieldErrors: {},
};
try {
const response = await fetch(buildNostrProfileUrl(accountId), {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(state.values),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; details?: unknown; persisted?: boolean }
| null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
host.nostrProfileFormState = {
...state,
saving: false,
error: errorMessage,
success: null,
fieldErrors: parseValidationErrors(data?.details),
};
return;
}
if (!data.persisted) {
host.nostrProfileFormState = {
...state,
saving: false,
error: "Profile publish failed on all relays.",
success: null,
};
return;
}
host.nostrProfileFormState = {
...state,
saving: false,
error: null,
success: "Profile published to relays.",
fieldErrors: {},
original: { ...state.values },
};
await loadChannels(host, true);
} catch (err) {
host.nostrProfileFormState = {
...state,
saving: false,
error: `Profile update failed: ${String(err)}`,
success: null,
};
}
}
export async function handleNostrProfileImport(host: MoltbotApp) {
const state = host.nostrProfileFormState;
if (!state || state.importing) return;
const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = {
...state,
importing: true,
error: null,
success: null,
};
try {
const response = await fetch(buildNostrProfileUrl(accountId, "/import"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ autoMerge: true }),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean }
| null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;
host.nostrProfileFormState = {
...state,
importing: false,
error: errorMessage,
success: null,
};
return;
}
const merged = data.merged ?? data.imported ?? null;
const nextValues = merged ? { ...state.values, ...merged } : state.values;
const showAdvanced = Boolean(
nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16,
);
host.nostrProfileFormState = {
...state,
importing: false,
values: nextValues,
error: null,
success: data.saved
? "Profile imported from relays. Review and publish."
: "Profile imported. Review and publish.",
showAdvanced,
};
if (data.saved) {
await loadChannels(host, true);
}
} catch (err) {
host.nostrProfileFormState = {
...state,
importing: false,
error: `Profile import failed: ${String(err)}`,
success: null,
};
}
}

View File

@@ -0,0 +1,208 @@
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
import { loadSessions } from "./controllers/sessions";
import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream";
import { scheduleChatScroll } from "./app-scroll";
import { setLastActiveSessionKey } from "./app-settings";
import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { MoltbotApp } from "./app";
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
type ChatHost = {
connected: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatRunId: string | null;
chatSending: boolean;
sessionKey: string;
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
};
export function isChatBusy(host: ChatHost) {
return host.chatSending || Boolean(host.chatRunId);
}
export function isChatStopCommand(text: string) {
const trimmed = text.trim();
if (!trimmed) return false;
const normalized = trimmed.toLowerCase();
if (normalized === "/stop") return true;
return (
normalized === "stop" ||
normalized === "esc" ||
normalized === "abort" ||
normalized === "wait" ||
normalized === "exit"
);
}
export async function handleAbortChat(host: ChatHost) {
if (!host.connected) return;
host.chatMessage = "";
await abortChatRun(host as unknown as MoltbotApp);
}
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) return;
host.chatQueue = [
...host.chatQueue,
{
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
},
];
}
async function sendChatMessageNow(
host: ChatHost,
message: string,
opts?: {
previousDraft?: string;
restoreDraft?: boolean;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
restoreAttachments?: boolean;
},
) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as MoltbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
if (!ok && opts?.previousAttachments) {
host.chatAttachments = opts.previousAttachments;
}
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
}
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
host.chatAttachments = opts.previousAttachments;
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
if (ok && !host.chatRunId) {
void flushChatQueue(host);
}
return ok;
}
async function flushChatQueue(host: ChatHost) {
if (!host.connected || isChatBusy(host)) return;
const [next, ...rest] = host.chatQueue;
if (!next) return;
host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
}
}
export function removeQueuedMessage(host: ChatHost, id: string) {
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
}
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: { restoreDraft?: boolean },
) {
if (!host.connected) return;
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
const attachments = host.chatAttachments ?? [];
const attachmentsToSend = messageOverride == null ? attachments : [];
const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) {
await handleAbortChat(host);
return;
}
if (messageOverride == null) {
host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = [];
}
if (isChatBusy(host)) {
enqueueChatMessage(host, message, attachmentsToSend);
return;
}
await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: messageOverride == null ? attachments : undefined,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
});
}
export async function refreshChat(host: ChatHost) {
await Promise.all([
loadChatHistory(host as unknown as MoltbotApp),
loadSessions(host as unknown as MoltbotApp),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
}
export const flushChatQueueForEvent = flushChatQueue;
type SessionDefaultsSnapshot = {
defaultAgentId?: string;
};
function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) return parsed.agentId;
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
return fallback || "main";
}
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
}
export async function refreshChatAvatar(host: ChatHost) {
if (!host.connected) {
host.chatAvatarUrl = null;
return;
}
const agentId = resolveAgentIdForSession(host);
if (!agentId) {
host.chatAvatarUrl = null;
return;
}
host.chatAvatarUrl = null;
const url = buildAvatarMetaUrl(host.basePath, agentId);
try {
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
host.chatAvatarUrl = null;
return;
}
const data = (await res.json()) as { avatarUrl?: unknown };
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
host.chatAvatarUrl = avatarUrl || null;
} catch {
host.chatAvatarUrl = null;
}
}

View File

@@ -0,0 +1,33 @@
import type { LogLevel } from "./types";
import type { CronFormState } from "./ui-types";
export const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
trace: true,
debug: true,
info: true,
warn: true,
error: true,
fatal: true,
};
export const DEFAULT_CRON_FORM: CronFormState = {
name: "",
description: "",
agentId: "",
enabled: true,
scheduleKind: "every",
scheduleAt: "",
everyAmount: "30",
everyUnit: "minutes",
cronExpr: "0 7 * * *",
cronTz: "",
sessionTarget: "main",
wakeMode: "next-heartbeat",
payloadKind: "systemEvent",
payloadText: "",
deliver: false,
channel: "last",
to: "",
timeoutSeconds: "",
postToMainPrefix: "",
};

View File

@@ -0,0 +1,5 @@
export type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};

View File

@@ -0,0 +1,258 @@
import { loadChatHistory } from "./controllers/chat";
import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes";
import { loadAgents } from "./controllers/agents";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
import { GatewayBrowserClient } from "./gateway";
import type { EventLogEntry } from "./app-events";
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { flushChatQueueForEvent } from "./app-chat";
import {
applySettings,
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
} from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import {
addExecApproval,
parseExecApprovalRequested,
parseExecApprovalResolved,
removeExecApproval,
} from "./controllers/exec-approval";
import type { MoltbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { loadAssistantIdentity } from "./controllers/assistant-identity";
type GatewayHost = {
settings: UiSettings;
password: string;
client: GatewayBrowserClient | null;
connected: boolean;
hello: GatewayHelloOk | null;
lastError: string | null;
onboarding?: boolean;
eventLogBuffer: EventLogEntry[];
eventLog: EventLogEntry[];
tab: Tab;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: StatusSummary | null;
agentsLoading: boolean;
agentsList: AgentsListResult | null;
agentsError: string | null;
debugHealth: HealthSnapshot | null;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
sessionKey: string;
chatRunId: string | null;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
};
type SessionDefaultsSnapshot = {
defaultAgentId?: string;
mainKey?: string;
mainSessionKey?: string;
scope?: string;
};
function normalizeSessionKeyForDefaults(
value: string | undefined,
defaults: SessionDefaultsSnapshot,
): string {
const raw = (value ?? "").trim();
const mainSessionKey = defaults.mainSessionKey?.trim();
if (!mainSessionKey) return raw;
if (!raw) return mainSessionKey;
const mainKey = defaults.mainKey?.trim() || "main";
const defaultAgentId = defaults.defaultAgentId?.trim();
const isAlias =
raw === "main" ||
raw === mainKey ||
(defaultAgentId &&
(raw === `agent:${defaultAgentId}:main` ||
raw === `agent:${defaultAgentId}:${mainKey}`));
return isAlias ? mainSessionKey : raw;
}
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
if (!defaults?.mainSessionKey) return;
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
host.settings.sessionKey,
defaults,
);
const resolvedLastActiveSessionKey = normalizeSessionKeyForDefaults(
host.settings.lastActiveSessionKey,
defaults,
);
const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey;
const nextSettings = {
...host.settings,
sessionKey: resolvedSettingsSessionKey || nextSessionKey,
lastActiveSessionKey: resolvedLastActiveSessionKey || nextSessionKey,
};
const shouldUpdateSettings =
nextSettings.sessionKey !== host.settings.sessionKey ||
nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey;
if (nextSessionKey !== host.sessionKey) {
host.sessionKey = nextSessionKey;
}
if (shouldUpdateSettings) {
applySettings(host as unknown as Parameters<typeof applySettings>[0], nextSettings);
}
}
export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = null;
host.connected = false;
host.execApprovalQueue = [];
host.execApprovalError = null;
host.client?.stop();
host.client = new GatewayBrowserClient({
url: host.settings.gatewayUrl,
token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined,
clientName: "moltbot-control-ui",
mode: "webchat",
onHello: (hello) => {
host.connected = true;
host.lastError = null;
host.hello = hello;
applySnapshot(host, hello);
// Reset orphaned chat run state from before disconnect.
// Any in-flight run's final event was lost during the disconnect window.
host.chatRunId = null;
(host as unknown as { chatStream: string | null }).chatStream = null;
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as MoltbotApp);
void loadAgents(host as unknown as MoltbotApp);
void loadNodes(host as unknown as MoltbotApp, { quiet: true });
void loadDevices(host as unknown as MoltbotApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
}
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onGap: ({ expected, received }) => {
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
host.client.start();
}
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
try {
handleGatewayEventUnsafe(host, evt);
} catch (err) {
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
}
}
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
host.eventLogBuffer = [
{ ts: Date.now(), event: evt.event, payload: evt.payload },
...host.eventLogBuffer,
].slice(0, 250);
if (host.tab === "debug") {
host.eventLog = host.eventLogBuffer;
}
if (evt.event === "agent") {
if (host.onboarding) return;
handleAgentEvent(
host as unknown as Parameters<typeof handleAgentEvent>[0],
evt.payload as AgentEventPayload | undefined,
);
return;
}
if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined;
if (payload?.sessionKey) {
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
payload.sessionKey,
);
}
const state = handleChatEvent(host as unknown as MoltbotApp, payload);
if (state === "final" || state === "error" || state === "aborted") {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
}
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
return;
}
if (evt.event === "presence") {
const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
if (payload?.presence && Array.isArray(payload.presence)) {
host.presenceEntries = payload.presence;
host.presenceError = null;
host.presenceStatus = null;
}
return;
}
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
void loadDevices(host as unknown as MoltbotApp, { quiet: true });
}
if (evt.event === "exec.approval.requested") {
const entry = parseExecApprovalRequested(evt.payload);
if (entry) {
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
host.execApprovalError = null;
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
window.setTimeout(() => {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
}, delay);
}
return;
}
if (evt.event === "exec.approval.resolved") {
const resolved = parseExecApprovalResolved(evt.payload);
if (resolved) {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
}
}
}
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as
| {
presence?: PresenceEntry[];
health?: HealthSnapshot;
sessionDefaults?: SessionDefaultsSnapshot;
}
| undefined;
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
host.presenceEntries = snapshot.presence;
}
if (snapshot?.health) {
host.debugHealth = snapshot.health;
}
if (snapshot?.sessionDefaults) {
applySessionDefaults(host, snapshot.sessionDefaults);
}
}

View File

@@ -0,0 +1,111 @@
import type { Tab } from "./navigation";
import { connectGateway } from "./app-gateway";
import {
applySettingsFromUrl,
attachThemeListener,
detachThemeListener,
inferBasePath,
syncTabWithLocation,
syncThemeWithSettings,
} from "./app-settings";
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import {
startLogsPolling,
startNodesPolling,
stopLogsPolling,
stopNodesPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
type LifecycleHost = {
basePath: string;
tab: Tab;
chatHasAutoScrolled: boolean;
chatLoading: boolean;
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string;
logsAutoFollow: boolean;
logsAtBottom: boolean;
logsEntries: unknown[];
popStateHandler: () => void;
topbarObserver: ResizeObserver | null;
};
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
syncTabWithLocation(
host as unknown as Parameters<typeof syncTabWithLocation>[0],
true,
);
syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
);
attachThemeListener(
host as unknown as Parameters<typeof attachThemeListener>[0],
);
window.addEventListener("popstate", host.popStateHandler);
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
}
if (host.tab === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
}
}
export function handleFirstUpdated(host: LifecycleHost) {
observeTopbar(host as unknown as Parameters<typeof observeTopbar>[0]);
}
export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
host.topbarObserver?.disconnect();
host.topbarObserver = null;
}
export function handleUpdated(
host: LifecycleHost,
changed: Map<PropertyKey, unknown>,
) {
if (
host.tab === "chat" &&
(changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") ||
changed.has("chatLoading") ||
changed.has("tab"))
) {
const forcedByTab = changed.has("tab");
const forcedByLoad =
changed.has("chatLoading") &&
changed.get("chatLoading") === true &&
host.chatLoading === false;
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[0],
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,
);
}
if (
host.tab === "logs" &&
(changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab"))
) {
if (host.logsAutoFollow && host.logsAtBottom) {
scheduleLogsScroll(
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
changed.has("tab") || changed.has("logsAutoFollow"),
);
}
}
}

View File

@@ -0,0 +1,53 @@
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadDebug } from "./controllers/debug";
import type { MoltbotApp } from "./app";
type PollingHost = {
nodesPollInterval: number | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
tab: string;
};
export function startNodesPolling(host: PollingHost) {
if (host.nodesPollInterval != null) return;
host.nodesPollInterval = window.setInterval(
() => void loadNodes(host as unknown as MoltbotApp, { quiet: true }),
5000,
);
}
export function stopNodesPolling(host: PollingHost) {
if (host.nodesPollInterval == null) return;
clearInterval(host.nodesPollInterval);
host.nodesPollInterval = null;
}
export function startLogsPolling(host: PollingHost) {
if (host.logsPollInterval != null) return;
host.logsPollInterval = window.setInterval(() => {
if (host.tab !== "logs") return;
void loadLogs(host as unknown as MoltbotApp, { quiet: true });
}, 2000);
}
export function stopLogsPolling(host: PollingHost) {
if (host.logsPollInterval == null) return;
clearInterval(host.logsPollInterval);
host.logsPollInterval = null;
}
export function startDebugPolling(host: PollingHost) {
if (host.debugPollInterval != null) return;
host.debugPollInterval = window.setInterval(() => {
if (host.tab !== "debug") return;
void loadDebug(host as unknown as MoltbotApp);
}, 3000);
}
export function stopDebugPolling(host: PollingHost) {
if (host.debugPollInterval == null) return;
clearInterval(host.debugPollInterval);
host.debugPollInterval = null;
}

View File

@@ -0,0 +1,242 @@
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
import { icons } from "./icons";
import { loadChatHistory } from "./controllers/chat";
import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath);
return html`
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab(tab);
}}
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
</a>
`;
}
export function renderChatControls(state: AppViewState) {
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(state, next, true);
void loadChatHistory(state);
}}
>
${repeat(
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
state.resetToolStream();
void loadChatHistory(state);
}}
title="Refresh chat history"
>
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) return;
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"}
>
${icons.brain}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@click=${() => {
if (disableFocusToggle) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}}
aria-pressed=${focusActive}
title=${disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"}
>
${focusIcon}
</button>
</div>
`;
}
function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add current session key first
seen.add(sessionKey);
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
// Add sessions from the result
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key)) {
seen.add(s.key);
options.push({ key: s.key, displayName: s.displayName });
}
}
}
return options;
}
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
if (event.clientX || event.clientY) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
};
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
</div>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
`;
}
function renderMoonIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
`;
}
function renderMonitorIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
`;
}

View File

@@ -0,0 +1,583 @@
import { html, nothing } from "lit";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { AppViewState } from "./app-view-state";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import {
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import { icons } from "./icons";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
ConfigSnapshot,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { refreshChatAvatar } from "./app-chat";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
import { renderChannels } from "./views/channels";
import { renderCron } from "./views/cron";
import { renderDebug } from "./views/debug";
import { renderInstances } from "./views/instances";
import { renderLogs } from "./views/logs";
import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval";
import {
approveDevicePairing,
loadDevices,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
} from "./controllers/devices";
import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import { loadChannels } from "./controllers/channels";
import { loadPresence } from "./controllers/presence";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
import {
installSkill,
loadSkills,
saveSkillApiKey,
updateSkillEdit,
updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills";
import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat";
import {
applyConfig,
loadConfig,
runUpdate,
saveConfig,
updateConfigFormValue,
removeConfigFormValue,
} from "./controllers/config";
import {
loadExecApprovals,
removeExecApprovalsFormValue,
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
const agentId =
parsed?.agentId ??
state.agentsList?.defaultId ??
"main";
const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar;
if (!candidate) return undefined;
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) return candidate;
return identity?.avatarUrl;
}
export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<header class="topbar">
<div class="topbar-left">
<button
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
<div class="brand">
<div class="brand-logo">
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="Moltbot" />
</div>
<div class="brand-text">
<div class="brand-title">MOLTBOT</div>
<div class="brand-sub">Gateway Dashboard</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
</div>
${renderThemeToggle(state)}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
</div>
`;
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
</div>
<div class="nav-group__items">
<a
class="nav-item nav-item--external"
href="https://docs.molt.bot"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
</a>
</div>
</div>
</aside>
<main class="content ${isChat ? "content--chat" : ""}">
<section class="content-header">
<div>
<div class="page-title">${titleForTab(state.tab)}</div>
<div class="page-sub">${subtitleForTab(state.tab)}</div>
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
${state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
${state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
${state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
${state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
${state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
${state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
</main>
${renderExecApprovalPrompt(state)}
</div>
`;
}

View File

@@ -0,0 +1,122 @@
type ScrollHost = {
updateComplete: Promise<unknown>;
querySelector: (selectors: string) => Element | null;
style: CSSStyleDeclaration;
chatScrollFrame: number | null;
chatScrollTimeout: number | null;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
logsScrollFrame: number | null;
logsAtBottom: boolean;
topbarObserver: ResizeObserver | null;
};
export function scheduleChatScroll(host: ScrollHost, force = false) {
if (host.chatScrollFrame) cancelAnimationFrame(host.chatScrollFrame);
if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
}
const pickScrollTarget = () => {
const container = host.querySelector(".chat-thread") as HTMLElement | null;
if (container) {
const overflowY = getComputedStyle(container).overflowY;
const canScroll =
overflowY === "auto" ||
overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1;
if (canScroll) return container;
}
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
};
// Wait for Lit render to complete, then scroll
void host.updateComplete.then(() => {
host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null;
const target = pickScrollTarget();
if (!target) return;
const distanceFromBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
if (!shouldStick) return;
if (force) host.chatHasAutoScrolled = true;
target.scrollTop = target.scrollHeight;
host.chatUserNearBottom = true;
const retryDelay = force ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
const latest = pickScrollTarget();
if (!latest) return;
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
if (!shouldStickRetry) return;
latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true;
}, retryDelay);
});
});
}
export function scheduleLogsScroll(host: ScrollHost, force = false) {
if (host.logsScrollFrame) cancelAnimationFrame(host.logsScrollFrame);
void host.updateComplete.then(() => {
host.logsScrollFrame = requestAnimationFrame(() => {
host.logsScrollFrame = null;
const container = host.querySelector(".log-stream") as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldStick = force || distanceFromBottom < 80;
if (!shouldStick) return;
container.scrollTop = container.scrollHeight;
});
});
}
export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200;
}
export function handleLogsScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80;
}
export function resetChatScroll(host: ScrollHost) {
host.chatHasAutoScrolled = false;
host.chatUserNearBottom = true;
}
export function exportLogs(lines: string[], label: string) {
if (lines.length === 0) return;
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
anchor.href = url;
anchor.download = `moltbot-logs-${label}-${stamp}.log`;
anchor.click();
URL.revokeObjectURL(url);
}
export function observeTopbar(host: ScrollHost) {
if (typeof ResizeObserver === "undefined") return;
const topbar = host.querySelector(".topbar");
if (!topbar) return;
const update = () => {
const { height } = topbar.getBoundingClientRect();
host.style.setProperty("--topbar-height", `${height}px`);
};
update();
host.topbarObserver = new ResizeObserver(() => update());
host.topbarObserver.observe(topbar);
}

View File

@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./navigation";
import { setTabFromRoute } from "./app-settings";
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
logsPollInterval: number | null;
debugPollInterval: number | null;
};
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
theme: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab,
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
});
describe("setTabFromRoute", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts and stops log polling based on the tab", () => {
const host = createHost("chat");
setTabFromRoute(host, "logs");
expect(host.logsPollInterval).not.toBeNull();
expect(host.debugPollInterval).toBeNull();
setTabFromRoute(host, "chat");
expect(host.logsPollInterval).toBeNull();
});
it("starts and stops debug polling based on the tab", () => {
const host = createHost("chat");
setTabFromRoute(host, "debug");
expect(host.debugPollInterval).not.toBeNull();
expect(host.logsPollInterval).toBeNull();
setTabFromRoute(host, "chat");
expect(host.debugPollInterval).toBeNull();
});
});

View File

@@ -0,0 +1,332 @@
import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes";
import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills";
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { MoltbotApp } from "./app";
type SettingsHost = {
settings: UiSettings;
theme: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
sessionKey: string;
tab: Tab;
connected: boolean;
chatHasAutoScrolled: boolean;
logsAtBottom: boolean;
eventLog: unknown[];
eventLogBuffer: unknown[];
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = {
...next,
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
};
host.settings = normalized;
saveSettings(normalized);
if (next.theme !== host.theme) {
host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme));
}
host.applySessionKey = host.settings.lastActiveSessionKey;
}
export function setLastActiveSessionKey(host: SettingsHost, next: string) {
const trimmed = next.trim();
if (!trimmed) return;
if (host.settings.lastActiveSessionKey === trimmed) return;
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
}
export function applySettingsFromUrl(host: SettingsHost) {
if (!window.location.search) return;
const params = new URLSearchParams(window.location.search);
const tokenRaw = params.get("token");
const passwordRaw = params.get("password");
const sessionRaw = params.get("session");
const gatewayUrlRaw = params.get("gatewayUrl");
let shouldCleanUrl = false;
if (tokenRaw != null) {
const token = tokenRaw.trim();
if (token && token !== host.settings.token) {
applySettings(host, { ...host.settings, token });
}
params.delete("token");
shouldCleanUrl = true;
}
if (passwordRaw != null) {
const password = passwordRaw.trim();
if (password) {
(host as { password: string }).password = password;
}
params.delete("password");
shouldCleanUrl = true;
}
if (sessionRaw != null) {
const session = sessionRaw.trim();
if (session) {
host.sessionKey = session;
applySettings(host, {
...host.settings,
sessionKey: session,
lastActiveSessionKey: session,
});
}
}
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
applySettings(host, { ...host.settings, gatewayUrl });
}
params.delete("gatewayUrl");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return;
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
}
export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
void refreshActiveTab(host);
syncUrlWithTab(host, next, false);
}
export function setTheme(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
applyResolvedTheme(host, resolveTheme(next));
};
startThemeTransition({
nextTheme: next,
applyTheme,
context,
currentTheme: host.theme,
});
}
export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "overview") await loadOverview(host);
if (host.tab === "channels") await loadChannelsTab(host);
if (host.tab === "instances") await loadPresence(host as unknown as MoltbotApp);
if (host.tab === "sessions") await loadSessions(host as unknown as MoltbotApp);
if (host.tab === "cron") await loadCron(host);
if (host.tab === "skills") await loadSkills(host as unknown as MoltbotApp);
if (host.tab === "nodes") {
await loadNodes(host as unknown as MoltbotApp);
await loadDevices(host as unknown as MoltbotApp);
await loadConfig(host as unknown as MoltbotApp);
await loadExecApprovals(host as unknown as MoltbotApp);
}
if (host.tab === "chat") {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[0],
!host.chatHasAutoScrolled,
);
}
if (host.tab === "config") {
await loadConfigSchema(host as unknown as MoltbotApp);
await loadConfig(host as unknown as MoltbotApp);
}
if (host.tab === "debug") {
await loadDebug(host as unknown as MoltbotApp);
host.eventLog = host.eventLogBuffer;
}
if (host.tab === "logs") {
host.logsAtBottom = true;
await loadLogs(host as unknown as MoltbotApp, { reset: true });
scheduleLogsScroll(
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
true,
);
}
}
export function inferBasePath() {
if (typeof window === "undefined") return "";
const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__;
if (typeof configured === "string" && configured.trim()) {
return normalizeBasePath(configured);
}
return inferBasePathFromPathname(window.location.pathname);
}
export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme));
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved;
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
}
export function attachThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") return;
applyResolvedTheme(host, event.matches ? "dark" : "light");
};
if (typeof host.themeMedia.addEventListener === "function") {
host.themeMedia.addEventListener("change", host.themeMediaHandler);
return;
}
const legacy = host.themeMedia as MediaQueryList & {
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.addListener(host.themeMediaHandler);
}
export function detachThemeListener(host: SettingsHost) {
if (!host.themeMedia || !host.themeMediaHandler) return;
if (typeof host.themeMedia.removeEventListener === "function") {
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
return;
}
const legacy = host.themeMedia as MediaQueryList & {
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.removeListener(host.themeMediaHandler);
host.themeMedia = null;
host.themeMediaHandler = null;
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
setTabFromRoute(host, resolved);
syncUrlWithTab(host, resolved, replace);
}
export function onPopState(host: SettingsHost) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath);
if (!resolved) return;
const url = new URL(window.location.href);
const session = url.searchParams.get("session")?.trim();
if (session) {
host.sessionKey = session;
applySettings(host, {
...host.settings,
sessionKey: session,
lastActiveSessionKey: session,
});
}
setTabFromRoute(host, resolved);
}
export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
if (host.connected) void refreshActiveTab(host);
}
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
if (typeof window === "undefined") return;
const targetPath = normalizePath(pathForTab(tab, host.basePath));
const currentPath = normalizePath(window.location.pathname);
const url = new URL(window.location.href);
if (tab === "chat" && host.sessionKey) {
url.searchParams.set("session", host.sessionKey);
} else {
url.searchParams.delete("session");
}
if (currentPath !== targetPath) {
url.pathname = targetPath;
}
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
export function syncUrlWithSessionKey(
host: SettingsHost,
sessionKey: string,
replace: boolean,
) {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set("session", sessionKey);
if (replace) window.history.replaceState({}, "", url.toString());
else window.history.pushState({}, "", url.toString());
}
export async function loadOverview(host: SettingsHost) {
await Promise.all([
loadChannels(host as unknown as MoltbotApp, false),
loadPresence(host as unknown as MoltbotApp),
loadSessions(host as unknown as MoltbotApp),
loadCronStatus(host as unknown as MoltbotApp),
loadDebug(host as unknown as MoltbotApp),
]);
}
export async function loadChannelsTab(host: SettingsHost) {
await Promise.all([
loadChannels(host as unknown as MoltbotApp, true),
loadConfigSchema(host as unknown as MoltbotApp),
loadConfig(host as unknown as MoltbotApp),
]);
}
export async function loadCron(host: SettingsHost) {
await Promise.all([
loadChannels(host as unknown as MoltbotApp, false),
loadCronStatus(host as unknown as MoltbotApp),
loadCronJobs(host as unknown as MoltbotApp),
]);
}

View File

@@ -0,0 +1,241 @@
import { truncateText } from "./format";
const TOOL_STREAM_LIMIT = 50;
const TOOL_STREAM_THROTTLE_MS = 80;
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
export type AgentEventPayload = {
runId: string;
seq: number;
stream: string;
ts: number;
sessionKey?: string;
data: Record<string, unknown>;
};
export type ToolStreamEntry = {
toolCallId: string;
runId: string;
sessionKey?: string;
name: string;
args?: unknown;
output?: string;
startedAt: number;
updatedAt: number;
message: Record<string, unknown>;
};
type ToolStreamHost = {
sessionKey: string;
chatRunId: string | null;
toolStreamById: Map<string, ToolStreamEntry>;
toolStreamOrder: string[];
chatToolMessages: Record<string, unknown>[];
toolStreamSyncTimer: number | null;
};
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.text === "string") return record.text;
const content = record.content;
if (!Array.isArray(content)) return null;
const parts = content
.map((item) => {
if (!item || typeof item !== "object") return null;
const entry = item as Record<string, unknown>;
if (entry.type === "text" && typeof entry.text === "string") return entry.text;
return null;
})
.filter((part): part is string => Boolean(part));
if (parts.length === 0) return null;
return parts.join("\n");
}
function formatToolOutput(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
const contentText = extractToolOutputText(value);
let text: string;
if (typeof value === "string") {
text = value;
} else if (contentText) {
text = contentText;
} else {
try {
text = JSON.stringify(value, null, 2);
} catch {
text = String(value);
}
}
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
if (!truncated.truncated) return truncated.text;
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
}
function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
const content: Array<Record<string, unknown>> = [];
content.push({
type: "toolcall",
name: entry.name,
arguments: entry.args ?? {},
});
if (entry.output) {
content.push({
type: "toolresult",
name: entry.name,
text: entry.output,
});
}
return {
role: "assistant",
toolCallId: entry.toolCallId,
runId: entry.runId,
content,
timestamp: entry.startedAt,
};
}
function trimToolStream(host: ToolStreamHost) {
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT;
const removed = host.toolStreamOrder.splice(0, overflow);
for (const id of removed) host.toolStreamById.delete(id);
}
function syncToolStreamMessages(host: ToolStreamHost) {
host.chatToolMessages = host.toolStreamOrder
.map((id) => host.toolStreamById.get(id)?.message)
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
}
export function flushToolStreamSync(host: ToolStreamHost) {
if (host.toolStreamSyncTimer != null) {
clearTimeout(host.toolStreamSyncTimer);
host.toolStreamSyncTimer = null;
}
syncToolStreamMessages(host);
}
export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
if (force) {
flushToolStreamSync(host);
return;
}
if (host.toolStreamSyncTimer != null) return;
host.toolStreamSyncTimer = window.setTimeout(
() => flushToolStreamSync(host),
TOOL_STREAM_THROTTLE_MS,
);
}
export function resetToolStream(host: ToolStreamHost) {
host.toolStreamById.clear();
host.toolStreamOrder = [];
host.chatToolMessages = [];
flushToolStreamSync(host);
}
export type CompactionStatus = {
active: boolean;
startedAt: number | null;
completedAt: number | null;
};
type CompactionHost = ToolStreamHost & {
compactionStatus?: CompactionStatus | null;
compactionClearTimer?: number | null;
};
const COMPACTION_TOAST_DURATION_MS = 5000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = typeof data.phase === "string" ? data.phase : "";
// Clear any existing timer
if (host.compactionClearTimer != null) {
window.clearTimeout(host.compactionClearTimer);
host.compactionClearTimer = null;
}
if (phase === "start") {
host.compactionStatus = {
active: true,
startedAt: Date.now(),
completedAt: null,
};
} else if (phase === "end") {
host.compactionStatus = {
active: false,
startedAt: host.compactionStatus?.startedAt ?? null,
completedAt: Date.now(),
};
// Auto-clear the toast after duration
host.compactionClearTimer = window.setTimeout(() => {
host.compactionStatus = null;
host.compactionClearTimer = null;
}, COMPACTION_TOAST_DURATION_MS);
}
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) return;
// Handle compaction events
if (payload.stream === "compaction") {
handleCompactionEvent(host as CompactionHost, payload);
return;
}
if (payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) return;
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
if (host.chatRunId && payload.runId !== host.chatRunId) return;
if (!host.chatRunId) return;
const data = payload.data ?? {};
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
if (!toolCallId) return;
const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined;
const output =
phase === "update"
? formatToolOutput(data.partialResult)
: phase === "result"
? formatToolOutput(data.result)
: undefined;
const now = Date.now();
let entry = host.toolStreamById.get(toolCallId);
if (!entry) {
entry = {
toolCallId,
runId: payload.runId,
sessionKey,
name,
args,
output,
startedAt: typeof payload.ts === "number" ? payload.ts : now,
updatedAt: now,
message: {},
};
host.toolStreamById.set(toolCallId, entry);
host.toolStreamOrder.push(toolCallId);
} else {
entry.name = name;
if (args !== undefined) entry.args = args;
if (output !== undefined) entry.output = output;
entry.updatedAt = now;
}
entry.message = buildToolStreamMessage(entry);
trimToolStream(host);
scheduleToolStreamSync(host, phase === "result");
}

View File

@@ -0,0 +1,206 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
AgentsListResult,
ChannelsStatusSnapshot,
ConfigSnapshot,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
NostrProfile,
PresenceEntry,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
export type AppViewState = {
settings: UiSettings;
password: string;
tab: Tab;
onboarding: boolean;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
sessionKey: string;
chatLoading: boolean;
chatSending: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
chatRunId: string | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaLoading: boolean;
configUiHints: Record<string, unknown>;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormMode: "form" | "raw";
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
nostrProfileFormState: NostrProfileFormState | null;
nostrProfileAccountId: string | null;
configFormDirty: boolean;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: string | null;
agentsLoading: boolean;
agentsList: AgentsListResult | null;
agentsError: string | null;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
sessionsFilterActive: string;
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronRunsJobId: string | null;
cronRuns: CronRunLogEntry[];
cronBusy: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown | null;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
logsLoading: boolean;
logsError: string | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsAutoFollow: boolean;
logsTruncated: boolean;
client: GatewayBrowserClient | null;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;
loadCron: () => Promise<void>;
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
handleNostrProfileCancel: () => void;
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
handleNostrProfileSave: () => Promise<void>;
handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
handleConfigFormUpdate: (path: string, value: unknown) => void;
handleConfigFormModeChange: (mode: "form" | "raw") => void;
handleConfigRawChange: (raw: string) => void;
handleInstallSkill: (key: string) => Promise<void>;
handleUpdateSkill: (key: string) => Promise<void>;
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
handleUpdateSkillEdit: (key: string, value: string) => void;
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
handleCronRun: (jobId: string) => Promise<void>;
handleCronRemove: (jobId: string) => Promise<void>;
handleCronAdd: () => Promise<void>;
handleCronRunsLoad: (jobId: string) => Promise<void>;
handleCronFormUpdate: (path: string, value: unknown) => void;
handleSessionsLoad: () => Promise<void>;
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
handleLoadNodes: () => Promise<void>;
handleLoadPresence: () => Promise<void>;
handleLoadSkills: () => Promise<void>;
handleLoadDebug: () => Promise<void>;
handleLoadLogs: () => Promise<void>;
handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setChatMessage: (next: string) => void;
handleChatSend: () => Promise<void>;
handleChatAbort: () => Promise<void>;
handleChatSelectQueueItem: (id: string) => void;
handleChatDropQueueItem: (id: string) => void;
handleChatClearQueue: () => void;
handleLogsFilterChange: (next: string) => void;
handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
};

View File

@@ -0,0 +1,485 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { resolveInjectedAssistantIdentity } from "./assistant-identity";
import { loadSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import type { Tab } from "./navigation";
import type { ResolvedTheme, ThemeMode } from "./theme";
import type {
AgentsListResult,
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
StatusSummary,
NostrProfile,
} from "./types";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
resetToolStream as resetToolStreamInternal,
type ToolStreamEntry,
} from "./app-tool-stream";
import {
exportLogs as exportLogsInternal,
handleChatScroll as handleChatScrollInternal,
handleLogsScroll as handleLogsScrollInternal,
resetChatScroll as resetChatScrollInternal,
} from "./app-scroll";
import { connectGateway as connectGatewayInternal } from "./app-gateway";
import {
handleConnected,
handleDisconnected,
handleFirstUpdated,
handleUpdated,
} from "./app-lifecycle";
import {
applySettings as applySettingsInternal,
loadCron as loadCronInternal,
loadOverview as loadOverviewInternal,
setTab as setTabInternal,
setTheme as setThemeInternal,
onPopState as onPopStateInternal,
} from "./app-settings";
import {
handleAbortChat as handleAbortChatInternal,
handleSendChat as handleSendChatInternal,
removeQueuedMessage as removeQueuedMessageInternal,
} from "./app-chat";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
handleChannelConfigSave as handleChannelConfigSaveInternal,
handleNostrProfileCancel as handleNostrProfileCancelInternal,
handleNostrProfileEdit as handleNostrProfileEditInternal,
handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal,
handleNostrProfileImport as handleNostrProfileImportInternal,
handleNostrProfileSave as handleNostrProfileSaveInternal,
handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal,
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
handleWhatsAppStart as handleWhatsAppStartInternal,
handleWhatsAppWait as handleWhatsAppWaitInternal,
} from "./app-channels";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
declare global {
interface Window {
__CLAWDBOT_CONTROL_UI_BASE_PATH__?: string;
}
}
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
function resolveOnboardingMode(): boolean {
if (!window.location.search) return false;
const params = new URLSearchParams(window.location.search);
const raw = params.get("onboarding");
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
@customElement("moltbot-app")
export class MoltbotApp extends LitElement {
@state() settings: UiSettings = loadSettings();
@state() password = "";
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() themeResolved: ResolvedTheme = "dark";
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;
private sidebarCloseTimer: number | null = null;
@state() assistantName = injectedAssistantIdentity.name;
@state() assistantAvatar = injectedAssistantIdentity.avatar;
@state() assistantAgentId = injectedAssistantIdentity.agentId ?? null;
@state() sessionKey = this.settings.sessionKey;
@state() chatLoading = false;
@state() chatSending = false;
@state() chatMessage = "";
@state() chatMessages: unknown[] = [];
@state() chatToolMessages: unknown[] = [];
@state() chatStream: string | null = null;
@state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null;
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@state() sidebarError: string | null = null;
@state() splitRatio = this.settings.splitRatio;
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() devicesLoading = false;
@state() devicesError: string | null = null;
@state() devicesList: DevicePairingList | null = null;
@state() execApprovalsLoading = false;
@state() execApprovalsSaving = false;
@state() execApprovalsDirty = false;
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@state() configRawOriginal = "";
@state() configValid: boolean | null = null;
@state() configIssues: unknown[] = [];
@state() configSaving = false;
@state() configApplying = false;
@state() updateRunning = false;
@state() applySessionKey = this.settings.lastActiveSessionKey;
@state() configSnapshot: ConfigSnapshot | null = null;
@state() configSchema: unknown | null = null;
@state() configSchemaVersion: string | null = null;
@state() configSchemaLoading = false;
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormOriginal: Record<string, unknown> | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
@state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
@state() channelsError: string | null = null;
@state() channelsLastSuccess: number | null = null;
@state() whatsappLoginMessage: string | null = null;
@state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null;
@state() whatsappBusy = false;
@state() nostrProfileFormState: NostrProfileFormState | null = null;
@state() nostrProfileAccountId: string | null = null;
@state() presenceLoading = false;
@state() presenceEntries: PresenceEntry[] = [];
@state() presenceError: string | null = null;
@state() presenceStatus: string | null = null;
@state() agentsLoading = false;
@state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | null = null;
@state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null;
@state() sessionsError: string | null = null;
@state() sessionsFilterActive = "";
@state() sessionsFilterLimit = "120";
@state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false;
@state() cronLoading = false;
@state() cronJobs: CronJob[] = [];
@state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronRunsJobId: string | null = null;
@state() cronRuns: CronRunLogEntry[] = [];
@state() cronBusy = false;
@state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@state() skillsFilter = "";
@state() skillEdits: Record<string, string> = {};
@state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {};
@state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSnapshot | null = null;
@state() debugModels: unknown[] = [];
@state() debugHeartbeat: unknown | null = null;
@state() debugCallMethod = "";
@state() debugCallParams = "{}";
@state() debugCallResult: string | null = null;
@state() debugCallError: string | null = null;
@state() logsLoading = false;
@state() logsError: string | null = null;
@state() logsFile: string | null = null;
@state() logsEntries: LogEntry[] = [];
@state() logsFilterText = "";
@state() logsLevelFilters: Record<LogLevel, boolean> = {
...DEFAULT_LOG_LEVEL_FILTERS,
};
@state() logsAutoFollow = true;
@state() logsTruncated = false;
@state() logsCursor: number | null = null;
@state() logsLastFetchAt: number | null = null;
@state() logsLimit = 500;
@state() logsMaxBytes = 250_000;
@state() logsAtBottom = true;
client: GatewayBrowserClient | null = null;
private chatScrollFrame: number | null = null;
private chatScrollTimeout: number | null = null;
private chatHasAutoScrolled = false;
private chatUserNearBottom = true;
private nodesPollInterval: number | null = null;
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;
private logsScrollFrame: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
basePath = "";
private popStateHandler = () =>
onPopStateInternal(
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
}
protected firstUpdated() {
handleFirstUpdated(this as unknown as Parameters<typeof handleFirstUpdated>[0]);
}
disconnectedCallback() {
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
}
connect() {
connectGatewayInternal(
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
}
handleChatScroll(event: Event) {
handleChatScrollInternal(
this as unknown as Parameters<typeof handleChatScrollInternal>[0],
event,
);
}
handleLogsScroll(event: Event) {
handleLogsScrollInternal(
this as unknown as Parameters<typeof handleLogsScrollInternal>[0],
event,
);
}
exportLogs(lines: string[], label: string) {
exportLogsInternal(lines, label);
}
resetToolStream() {
resetToolStreamInternal(
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
}
resetChatScroll() {
resetChatScrollInternal(
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
}
async loadAssistantIdentity() {
await loadAssistantIdentityInternal(this);
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
}
setTab(next: Tab) {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
}
async loadOverview() {
await loadOverviewInternal(
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
}
async loadCron() {
await loadCronInternal(
this as unknown as Parameters<typeof loadCronInternal>[0],
);
}
async handleAbortChat() {
await handleAbortChatInternal(
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
}
removeQueuedMessage(id: string) {
removeQueuedMessageInternal(
this as unknown as Parameters<typeof removeQueuedMessageInternal>[0],
id,
);
}
async handleSendChat(
messageOverride?: string,
opts?: Parameters<typeof handleSendChatInternal>[2],
) {
await handleSendChatInternal(
this as unknown as Parameters<typeof handleSendChatInternal>[0],
messageOverride,
opts,
);
}
async handleWhatsAppStart(force: boolean) {
await handleWhatsAppStartInternal(this, force);
}
async handleWhatsAppWait() {
await handleWhatsAppWaitInternal(this);
}
async handleWhatsAppLogout() {
await handleWhatsAppLogoutInternal(this);
}
async handleChannelConfigSave() {
await handleChannelConfigSaveInternal(this);
}
async handleChannelConfigReload() {
await handleChannelConfigReloadInternal(this);
}
handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) {
handleNostrProfileEditInternal(this, accountId, profile);
}
handleNostrProfileCancel() {
handleNostrProfileCancelInternal(this);
}
handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) {
handleNostrProfileFieldChangeInternal(this, field, value);
}
async handleNostrProfileSave() {
await handleNostrProfileSaveInternal(this);
}
async handleNostrProfileImport() {
await handleNostrProfileImportInternal(this);
}
handleNostrProfileToggleAdvanced() {
handleNostrProfileToggleAdvancedInternal(this);
}
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) return;
this.execApprovalBusy = true;
this.execApprovalError = null;
try {
await this.client.request("exec.approval.resolve", {
id: active.id,
decision,
});
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
} catch (err) {
this.execApprovalError = `Exec approval failed: ${String(err)}`;
} finally {
this.execApprovalBusy = false;
}
}
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {
window.clearTimeout(this.sidebarCloseTimer);
this.sidebarCloseTimer = null;
}
this.sidebarContent = content;
this.sidebarError = null;
this.sidebarOpen = true;
}
handleCloseSidebar() {
this.sidebarOpen = false;
// Clear content after transition
if (this.sidebarCloseTimer != null) {
window.clearTimeout(this.sidebarCloseTimer);
}
this.sidebarCloseTimer = window.setTimeout(() => {
if (this.sidebarOpen) return;
this.sidebarContent = null;
this.sidebarError = null;
this.sidebarCloseTimer = null;
}, 200);
}
handleSplitRatioChange(ratio: number) {
const newRatio = Math.max(0.4, Math.min(0.7, ratio));
this.splitRatio = newRatio;
this.applySettings({ ...this.settings, splitRatio: newRatio });
}
render() {
return renderApp(this);
}
}

View File

@@ -0,0 +1,49 @@
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
export const DEFAULT_ASSISTANT_NAME = "Assistant";
export const DEFAULT_ASSISTANT_AVATAR = "A";
export type AssistantIdentity = {
agentId?: string | null;
name: string;
avatar: string | null;
};
declare global {
interface Window {
__CLAWDBOT_ASSISTANT_NAME__?: string;
__CLAWDBOT_ASSISTANT_AVATAR__?: string;
}
}
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length <= maxLength) return trimmed;
return trimmed.slice(0, maxLength);
}
export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null,
): AssistantIdentity {
const name =
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
const agentId =
typeof input?.agentId === "string" && input.agentId.trim()
? input.agentId.trim()
: null;
return { agentId, name, avatar };
}
export function resolveInjectedAssistantIdentity(): AssistantIdentity {
if (typeof window === "undefined") {
return normalizeAssistantIdentity({});
}
return normalizeAssistantIdentity({
name: window.__CLAWDBOT_ASSISTANT_NAME__,
avatar: window.__CLAWDBOT_ASSISTANT_AVATAR__,
});
}

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MoltbotApp } from "./app";
const originalConnect = MoltbotApp.prototype.connect;
function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
const app = document.createElement("moltbot-app") as MoltbotApp;
document.body.append(app);
return app;
}
beforeEach(() => {
MoltbotApp.prototype.connect = () => {
// no-op: avoid real gateway WS connections in browser tests
};
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
afterEach(() => {
MoltbotApp.prototype.connect = originalConnect;
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
describe("chat markdown rendering", () => {
it("renders markdown inside tool output sidebar", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const timestamp = Date.now();
app.chatMessages = [
{
role: "assistant",
content: [
{ type: "toolcall", name: "noop", arguments: {} },
{ type: "toolresult", name: "noop", text: "Hello **world**" },
],
timestamp,
},
];
await app.updateComplete;
const toolCards = Array.from(
app.querySelectorAll<HTMLElement>(".chat-tool-card"),
);
const toolCard = toolCards.find((card) =>
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
);
expect(toolCard).not.toBeUndefined();
toolCard?.click();
await app.updateComplete;
const strong = app.querySelector(".sidebar-markdown strong");
expect(strong?.textContent).toBe("world");
});
});

View File

@@ -0,0 +1,12 @@
/**
* Chat-related constants for the UI layer.
*/
/** Character threshold for showing tool output inline vs collapsed */
export const TOOL_INLINE_THRESHOLD = 80;
/** Maximum lines to show in collapsed preview */
export const PREVIEW_MAX_LINES = 2;
/** Maximum characters to show in collapsed preview */
export const PREVIEW_MAX_CHARS = 100;

View File

@@ -0,0 +1,90 @@
import { html, type TemplateResult } from "lit";
import { icons } from "../icons";
const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed";
type CopyButtonOptions = {
text: () => string;
label?: string;
};
async function copyTextToClipboard(text: string): Promise<boolean> {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function setButtonLabel(button: HTMLButtonElement, label: string) {
button.title = label;
button.setAttribute("aria-label", label);
}
function createCopyButton(options: CopyButtonOptions): TemplateResult {
const idleLabel = options.label ?? COPY_LABEL;
return html`
<button
class="chat-copy-btn"
type="button"
title=${idleLabel}
aria-label=${idleLabel}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
const iconContainer = btn?.querySelector(
".chat-copy-btn__icon",
) as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") return;
btn.dataset.copying = "1";
btn.setAttribute("aria-busy", "true");
btn.disabled = true;
const copied = await copyTextToClipboard(options.text());
if (!btn.isConnected) return;
delete btn.dataset.copying;
btn.removeAttribute("aria-busy");
btn.disabled = false;
if (!copied) {
btn.dataset.error = "1";
setButtonLabel(btn, ERROR_LABEL);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.error;
setButtonLabel(btn, idleLabel);
}, ERROR_FOR_MS);
return;
}
btn.dataset.copied = "1";
setButtonLabel(btn, COPIED_LABEL);
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.copied;
setButtonLabel(btn, idleLabel);
}, COPIED_FOR_MS);
}}
>
<span class="chat-copy-btn__icon" aria-hidden="true">
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
<span class="chat-copy-btn__icon-check">${icons.check}</span>
</span>
</button>
`;
}
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
return createCopyButton({ text: () => markdown, label: COPY_LABEL });
}

View File

@@ -0,0 +1,290 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity";
import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
import {
extractTextCached,
extractThinkingCached,
formatReasoningMarkdown,
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
type ImageBlock = {
url: string;
alt?: string;
};
function extractImages(message: unknown): ImageBlock[] {
const m = message as Record<string, unknown>;
const content = m.content;
const images: ImageBlock[] = [];
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block !== "object" || block === null) continue;
const b = block as Record<string, unknown>;
if (b.type === "image") {
// Handle source object format (from sendChatMessage)
const source = b.source as Record<string, unknown> | undefined;
if (source?.type === "base64" && typeof source.data === "string") {
const data = source.data as string;
const mediaType = (source.media_type as string) || "image/png";
// If data is already a data URL, use it directly
const url = data.startsWith("data:")
? data
: `data:${mediaType};base64,${data}`;
images.push({ url });
} else if (typeof b.url === "string") {
images.push({ url: b.url });
}
} else if (b.type === "image_url") {
// OpenAI format
const imageUrl = b.image_url as Record<string, unknown> | undefined;
if (typeof imageUrl?.url === "string") {
images.push({ url: imageUrl.url });
}
}
}
}
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
export function renderStreamingGroup(
text: string,
startedAt: number,
onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const name = assistant?.name ?? "Assistant";
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
role: "assistant",
content: [{ type: "text", text }],
timestamp: startedAt,
},
{ isStreaming: true, showReasoning: false },
onOpenSidebar,
)}
<div class="chat-group-footer">
<span class="chat-sender-name">${name}</span>
<span class="chat-group-timestamp">${timestamp}</span>
</div>
</div>
</div>
`;
}
export function renderMessageGroup(
group: MessageGroup,
opts: {
onOpenSidebar?: (content: string) => void;
showReasoning: boolean;
assistantName?: string;
assistantAvatar?: string | null;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const assistantName = opts.assistantName ?? "Assistant";
const who =
normalizedRole === "user"
? "You"
: normalizedRole === "assistant"
? assistantName
: normalizedRole;
const roleClass =
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(group.role, {
name: assistantName,
avatar: opts.assistantAvatar ?? null,
})}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
renderGroupedMessage(
item.message,
{
isStreaming:
group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
},
opts.onOpenSidebar,
),
)}
<div class="chat-group-footer">
<span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span>
</div>
</div>
</div>
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
const initial =
normalized === "user"
? "U"
: normalized === "assistant"
? assistantName.charAt(0).toUpperCase() || "A"
: normalized === "tool"
? "⚙"
: "?";
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other";
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) ||
/^\//.test(value) // Relative paths from avatar endpoint
);
}
function renderMessageImages(images: ImageBlock[]) {
if (images.length === 0) return nothing;
return html`
<div class="chat-message-images">
${images.map(
(img) => html`
<img
src=${img.url}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => window.open(img.url, "_blank")}
/>
`,
)}
</div>
`;
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
onOpenSidebar?: (content: string) => void,
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const isToolResult =
isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const hasImages = images.length > 0;
const extractedText = extractTextCached(message);
const extractedThinking =
opts.showReasoning && role === "assistant"
? extractThinkingCached(message)
: null;
const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
.filter(Boolean)
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) =>
renderToolCardSidebar(card, onOpenSidebar),
)}`;
}
if (!markdown && !hasToolCards && !hasImages) return nothing;
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
extractText,
extractTextCached,
extractThinking,
extractThinkingCached,
} from "./message-extract";
describe("extractTextCached", () => {
it("matches extractText output", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "Hello there" }],
};
expect(extractTextCached(message)).toBe(extractText(message));
});
it("returns consistent output for repeated calls", () => {
const message = {
role: "user",
content: "plain text",
};
expect(extractTextCached(message)).toBe("plain text");
expect(extractTextCached(message)).toBe("plain text");
});
});
describe("extractThinkingCached", () => {
it("matches extractThinking output", () => {
const message = {
role: "assistant",
content: [{ type: "thinking", thinking: "Plan A" }],
};
expect(extractThinkingCached(message)).toBe(extractThinking(message));
});
it("returns consistent output for repeated calls", () => {
const message = {
role: "assistant",
content: [{ type: "thinking", thinking: "Plan A" }],
};
expect(extractThinkingCached(message)).toBe("Plan A");
expect(extractThinkingCached(message)).toBe("Plan A");
});
});

View File

@@ -0,0 +1,139 @@
import { stripThinkingTags } from "../format";
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
const ENVELOPE_CHANNELS = [
"WebChat",
"WhatsApp",
"Telegram",
"Signal",
"Slack",
"Discord",
"iMessage",
"Teams",
"Matrix",
"Zalo",
"Zalo Personal",
"BlueBubbles",
];
const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
function looksLikeEnvelopeHeader(header: string): boolean {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
}
export function stripEnvelope(text: string): string {
const match = text.match(ENVELOPE_PREFIX);
if (!match) return text;
const header = match[1] ?? "";
if (!looksLikeEnvelopeHeader(header)) return text;
return text.slice(match[0].length);
}
export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
const content = m.content;
if (typeof content === "string") {
const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content);
return processed;
}
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
const joined = parts.join("\n");
const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined);
return processed;
}
}
if (typeof m.text === "string") {
const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text);
return processed;
}
return null;
}
export function extractTextCached(message: unknown): string | null {
if (!message || typeof message !== "object") return extractText(message);
const obj = message as object;
if (textCache.has(obj)) return textCache.get(obj) ?? null;
const value = extractText(message);
textCache.set(obj, value);
return value;
}
export function extractThinking(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
const parts: string[] = [];
if (Array.isArray(content)) {
for (const p of content) {
const item = p as Record<string, unknown>;
if (item.type === "thinking" && typeof item.thinking === "string") {
const cleaned = item.thinking.trim();
if (cleaned) parts.push(cleaned);
}
}
}
if (parts.length > 0) return parts.join("\n");
// Back-compat: older logs may still have <think> tags inside text blocks.
const rawText = extractRawText(message);
if (!rawText) return null;
const matches = [
...rawText.matchAll(
/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi,
),
];
const extracted = matches
.map((m) => (m[1] ?? "").trim())
.filter(Boolean);
return extracted.length > 0 ? extracted.join("\n") : null;
}
export function extractThinkingCached(message: unknown): string | null {
if (!message || typeof message !== "object") return extractThinking(message);
const obj = message as object;
if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null;
const value = extractThinking(message);
thinkingCache.set(obj, value);
return value;
}
export function extractRawText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
}
export function formatReasoningMarkdown(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
const lines = trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => `_${line}_`);
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
}

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
normalizeMessage,
normalizeRoleForGrouping,
isToolResultMessage,
} from "./message-normalizer";
describe("message-normalizer", () => {
describe("normalizeMessage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("normalizes message with string content", () => {
const result = normalizeMessage({
role: "user",
content: "Hello world",
timestamp: 1000,
id: "msg-1",
});
expect(result).toEqual({
role: "user",
content: [{ type: "text", text: "Hello world" }],
timestamp: 1000,
id: "msg-1",
});
});
it("normalizes message with array content", () => {
const result = normalizeMessage({
role: "assistant",
content: [
{ type: "text", text: "Here is the result" },
{ type: "tool_use", name: "bash", args: { command: "ls" } },
],
timestamp: 2000,
});
expect(result.role).toBe("assistant");
expect(result.content).toHaveLength(2);
expect(result.content[0]).toEqual({ type: "text", text: "Here is the result", name: undefined, args: undefined });
expect(result.content[1]).toEqual({ type: "tool_use", text: undefined, name: "bash", args: { command: "ls" } });
});
it("normalizes message with text field (alternative format)", () => {
const result = normalizeMessage({
role: "user",
text: "Alternative format",
});
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
});
it("detects tool result by toolCallId", () => {
const result = normalizeMessage({
role: "assistant",
toolCallId: "call-123",
content: "Tool output",
});
expect(result.role).toBe("toolResult");
});
it("detects tool result by tool_call_id (snake_case)", () => {
const result = normalizeMessage({
role: "assistant",
tool_call_id: "call-456",
content: "Tool output",
});
expect(result.role).toBe("toolResult");
});
it("handles missing role", () => {
const result = normalizeMessage({ content: "No role" });
expect(result.role).toBe("unknown");
});
it("handles missing content", () => {
const result = normalizeMessage({ role: "user" });
expect(result.content).toEqual([]);
});
it("uses current timestamp when not provided", () => {
const result = normalizeMessage({ role: "user", content: "Test" });
expect(result.timestamp).toBe(Date.now());
});
it("handles arguments field (alternative to args)", () => {
const result = normalizeMessage({
role: "assistant",
content: [{ type: "tool_use", name: "test", arguments: { foo: "bar" } }],
});
expect(result.content[0].args).toEqual({ foo: "bar" });
});
});
describe("normalizeRoleForGrouping", () => {
it("returns tool for toolresult", () => {
expect(normalizeRoleForGrouping("toolresult")).toBe("tool");
expect(normalizeRoleForGrouping("toolResult")).toBe("tool");
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool");
});
it("returns tool for tool_result", () => {
expect(normalizeRoleForGrouping("tool_result")).toBe("tool");
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool");
});
it("returns tool for tool", () => {
expect(normalizeRoleForGrouping("tool")).toBe("tool");
expect(normalizeRoleForGrouping("Tool")).toBe("tool");
});
it("returns tool for function", () => {
expect(normalizeRoleForGrouping("function")).toBe("tool");
expect(normalizeRoleForGrouping("Function")).toBe("tool");
});
it("preserves user role", () => {
expect(normalizeRoleForGrouping("user")).toBe("user");
expect(normalizeRoleForGrouping("User")).toBe("User");
});
it("preserves assistant role", () => {
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
});
it("preserves system role", () => {
expect(normalizeRoleForGrouping("system")).toBe("system");
});
});
describe("isToolResultMessage", () => {
it("returns true for toolresult role", () => {
expect(isToolResultMessage({ role: "toolresult" })).toBe(true);
expect(isToolResultMessage({ role: "toolResult" })).toBe(true);
expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true);
});
it("returns true for tool_result role", () => {
expect(isToolResultMessage({ role: "tool_result" })).toBe(true);
expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true);
});
it("returns false for other roles", () => {
expect(isToolResultMessage({ role: "user" })).toBe(false);
expect(isToolResultMessage({ role: "assistant" })).toBe(false);
expect(isToolResultMessage({ role: "tool" })).toBe(false);
});
it("returns false for missing role", () => {
expect(isToolResultMessage({})).toBe(false);
expect(isToolResultMessage({ content: "test" })).toBe(false);
});
it("returns false for non-string role", () => {
expect(isToolResultMessage({ role: 123 })).toBe(false);
expect(isToolResultMessage({ role: null })).toBe(false);
});
});
});

View File

@@ -0,0 +1,90 @@
/**
* Message normalization utilities for chat rendering.
*/
import type {
NormalizedMessage,
MessageContentItem,
} from "../types/chat-types";
/**
* Normalize a raw message object into a consistent structure.
*/
export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>;
let role = typeof m.role === "string" ? m.role : "unknown";
// Detect tool messages by common gateway shapes.
// Some tool events come through as assistant role with tool_* items in the content array.
const hasToolId =
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const contentRaw = m.content;
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
const hasToolContent =
Array.isArray(contentItems) &&
contentItems.some((item) => {
const x = item as Record<string, unknown>;
const t = String(x.type ?? "").toLowerCase();
return t === "toolresult" || t === "tool_result";
});
const hasToolName =
typeof (m as Record<string, unknown>).toolName === "string" ||
typeof (m as Record<string, unknown>).tool_name === "string";
if (hasToolId || hasToolContent || hasToolName) {
role = "toolResult";
}
// Extract content
let content: MessageContentItem[] = [];
if (typeof m.content === "string") {
content = [{ type: "text", text: m.content }];
} else if (Array.isArray(m.content)) {
content = m.content.map((item: Record<string, unknown>) => ({
type: (item.type as MessageContentItem["type"]) || "text",
text: item.text as string | undefined,
name: item.name as string | undefined,
args: item.args || item.arguments,
}));
} else if (typeof m.text === "string") {
content = [{ type: "text", text: m.text }];
}
const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now();
const id = typeof m.id === "string" ? m.id : undefined;
return { role, content, timestamp, id };
}
/**
* Normalize role for grouping purposes.
*/
export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase();
// Preserve original casing when it's already a core role.
if (role === "user" || role === "User") return role;
if (role === "assistant") return "assistant";
if (role === "system") return "system";
// Keep tool-related roles distinct so the UI can style/toggle them.
if (
lower === "toolresult" ||
lower === "tool_result" ||
lower === "tool" ||
lower === "function"
) {
return "tool";
}
return role;
}
/**
* Check if a message is a tool result message based on its role.
*/
export function isToolResultMessage(message: unknown): boolean {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
return role === "toolresult" || role === "tool_result";
}

View File

@@ -0,0 +1,144 @@
import { html, nothing } from "lit";
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import { icons } from "../icons";
import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants";
import {
formatToolOutputForSidebar,
getTruncatedPreview,
} from "./tool-helpers";
import { isToolResultMessage } from "./message-normalizer";
import { extractTextCached } from "./message-extract";
export function extractToolCards(message: unknown): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const cards: ToolCard[] = [];
for (const item of content) {
const kind = String(item.type ?? "").toLowerCase();
const isToolCall =
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
(typeof item.name === "string" && item.arguments != null);
if (isToolCall) {
cards.push({
kind: "call",
name: (item.name as string) ?? "tool",
args: coerceArgs(item.arguments ?? item.args),
});
}
}
for (const item of content) {
const kind = String(item.type ?? "").toLowerCase();
if (kind !== "toolresult" && kind !== "tool_result") continue;
const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text });
}
if (
isToolResultMessage(message) &&
!cards.some((card) => card.kind === "result")
) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
"tool";
const text = extractTextCached(message) ?? undefined;
cards.push({ kind: "result", name, text });
}
return cards;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: string) => void,
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim());
const canClick = Boolean(onOpenSidebar);
const handleClick = canClick
? () => {
if (hasText) {
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
return;
}
const info = `## ${display.label}\n\n${
detail ? `**Command:** \`${detail}\`\n\n` : ""
}*No output — tool completed successfully.*`;
onOpenSidebar!(info);
}
: undefined;
const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD;
const showCollapsed = hasText && !isShort;
const showInline = hasText && isShort;
const isEmpty = !hasText;
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick?.();
}
: nothing}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
: nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline
? html`<div class="chat-tool-card__inline mono">${card.text}</div>`
: nothing}
</div>
`;
}
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(content)) return [];
return content.filter(Boolean) as Array<Record<string, unknown>>;
}
function coerceArgs(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed) return value;
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.text === "string") return item.text;
if (typeof item.content === "string") return item.content;
return undefined;
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers";
describe("tool-helpers", () => {
describe("formatToolOutputForSidebar", () => {
it("formats valid JSON object as code block", () => {
const input = '{"name":"test","value":123}';
const result = formatToolOutputForSidebar(input);
expect(result).toBe(`\`\`\`json
{
"name": "test",
"value": 123
}
\`\`\``);
});
it("formats valid JSON array as code block", () => {
const input = '[1, 2, 3]';
const result = formatToolOutputForSidebar(input);
expect(result).toBe(`\`\`\`json
[
1,
2,
3
]
\`\`\``);
});
it("handles nested JSON objects", () => {
const input = '{"outer":{"inner":"value"}}';
const result = formatToolOutputForSidebar(input);
expect(result).toContain("```json");
expect(result).toContain('"outer"');
expect(result).toContain('"inner"');
});
it("returns plain text for non-JSON content", () => {
const input = "This is plain text output";
const result = formatToolOutputForSidebar(input);
expect(result).toBe("This is plain text output");
});
it("returns as-is for invalid JSON starting with {", () => {
const input = "{not valid json";
const result = formatToolOutputForSidebar(input);
expect(result).toBe("{not valid json");
});
it("returns as-is for invalid JSON starting with [", () => {
const input = "[not valid json";
const result = formatToolOutputForSidebar(input);
expect(result).toBe("[not valid json");
});
it("trims whitespace before detecting JSON", () => {
const input = ' {"trimmed": true} ';
const result = formatToolOutputForSidebar(input);
expect(result).toContain("```json");
expect(result).toContain('"trimmed"');
});
it("handles empty string", () => {
const result = formatToolOutputForSidebar("");
expect(result).toBe("");
});
it("handles whitespace-only string", () => {
const result = formatToolOutputForSidebar(" ");
expect(result).toBe(" ");
});
});
describe("getTruncatedPreview", () => {
it("returns short text unchanged", () => {
const input = "Short text";
const result = getTruncatedPreview(input);
expect(result).toBe("Short text");
});
it("truncates text longer than max chars", () => {
const input = "a".repeat(150);
const result = getTruncatedPreview(input);
expect(result.length).toBe(101); // 100 chars + ellipsis
expect(result.endsWith("…")).toBe(true);
});
it("truncates to max lines", () => {
const input = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
const result = getTruncatedPreview(input);
// Should only show first 2 lines (PREVIEW_MAX_LINES = 2)
expect(result).toBe("Line 1\nLine 2…");
});
it("adds ellipsis when lines are truncated", () => {
const input = "Line 1\nLine 2\nLine 3";
const result = getTruncatedPreview(input);
expect(result.endsWith("…")).toBe(true);
});
it("does not add ellipsis when all lines fit", () => {
const input = "Line 1\nLine 2";
const result = getTruncatedPreview(input);
expect(result).toBe("Line 1\nLine 2");
expect(result.endsWith("…")).toBe(false);
});
it("handles single line within limits", () => {
const input = "Single line";
const result = getTruncatedPreview(input);
expect(result).toBe("Single line");
});
it("handles empty string", () => {
const result = getTruncatedPreview("");
expect(result).toBe("");
});
it("truncates by chars even within line limit", () => {
// Two lines but very long content
const longLine = "x".repeat(80);
const input = `${longLine}\n${longLine}`;
const result = getTruncatedPreview(input);
expect(result.length).toBe(101); // 100 + ellipsis
expect(result.endsWith("…")).toBe(true);
});
});
});

View File

@@ -0,0 +1,37 @@
/**
* Helper functions for tool card rendering.
*/
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants";
/**
* Format tool output content for display in the sidebar.
* Detects JSON and wraps it in a code block with formatting.
*/
export function formatToolOutputForSidebar(text: string): string {
const trimmed = text.trim();
// Try to detect and format JSON
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
} catch {
// Not valid JSON, return as-is
}
}
return text;
}
/**
* Get a truncated preview of tool output text.
* Truncates to first N lines or first N characters, whichever is shorter.
*/
export function getTruncatedPreview(text: string): string {
const allLines = text.split("\n");
const lines = allLines.slice(0, PREVIEW_MAX_LINES);
const preview = lines.join("\n");
if (preview.length > PREVIEW_MAX_CHARS) {
return preview.slice(0, PREVIEW_MAX_CHARS) + "…";
}
return lines.length < allLines.length ? preview + "…" : preview;
}

View File

@@ -0,0 +1,109 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
* A draggable divider for resizable split views.
* Dispatches 'resize' events with { splitRatio: number } detail.
*/
@customElement("resizable-divider")
export class ResizableDivider extends LitElement {
@property({ type: Number }) splitRatio = 0.6;
@property({ type: Number }) minRatio = 0.4;
@property({ type: Number }) maxRatio = 0.7;
private isDragging = false;
private startX = 0;
private startRatio = 0;
static styles = css`
:host {
width: 4px;
cursor: col-resize;
background: var(--border, #333);
transition: background 150ms ease-out;
flex-shrink: 0;
position: relative;
}
:host::before {
content: "";
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
}
:host(:hover) {
background: var(--accent, #007bff);
}
:host(.dragging) {
background: var(--accent, #007bff);
}
`;
render() {
return html``;
}
connectedCallback() {
super.connectedCallback();
this.addEventListener("mousedown", this.handleMouseDown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("mousedown", this.handleMouseDown);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
}
private handleMouseDown = (e: MouseEvent) => {
this.isDragging = true;
this.startX = e.clientX;
this.startRatio = this.splitRatio;
this.classList.add("dragging");
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const container = this.parentElement;
if (!container) return;
const containerWidth = container.getBoundingClientRect().width;
const deltaX = e.clientX - this.startX;
const deltaRatio = deltaX / containerWidth;
let newRatio = this.startRatio + deltaRatio;
newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio));
this.dispatchEvent(
new CustomEvent("resize", {
detail: { splitRatio: newRatio },
bubbles: true,
composed: true,
})
);
};
private handleMouseUp = () => {
this.isDragging = false;
this.classList.remove("dragging");
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
}
declare global {
interface HTMLElementTagNameMap {
"resizable-divider": ResizableDivider;
}
}

View File

@@ -0,0 +1,274 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
const rootSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
allowFrom: {
type: "array",
items: { type: "string" },
},
mode: {
type: "string",
enum: ["off", "token"],
},
enabled: {
type: "boolean",
},
bind: {
anyOf: [
{ const: "auto" },
{ const: "lan" },
{ const: "tailnet" },
{ const: "loopback" },
],
},
},
};
describe("config form renderer", () => {
it("renders inputs and patches values", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
onPatch,
}),
container,
);
const tokenInput = container.querySelector(
"input[type='password']",
) as HTMLInputElement | null;
expect(tokenInput).not.toBeNull();
if (!tokenInput) return;
tokenInput.value = "abc123";
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(
["gateway", "auth", "token"],
"abc123",
);
const tokenButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
).find((btn) => btn.textContent?.trim() === "token");
expect(tokenButton).not.toBeUndefined();
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector(
"input[type='checkbox']",
) as HTMLInputElement | null;
expect(checkbox).not.toBeNull();
if (!checkbox) return;
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
});
it("adds and removes array entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {},
unsupportedPaths: analysis.unsupportedPaths,
value: { allowFrom: ["+1"] },
onPatch,
}),
container,
);
const addButton = container.querySelector(
".cfg-array__add",
) as HTMLButtonElement | null;
expect(addButton).not.toBeUndefined();
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
const removeButton = container.querySelector(
".cfg-array__item-remove",
) as HTMLButtonElement | null;
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
});
it("renders union literals as select options", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {},
unsupportedPaths: analysis.unsupportedPaths,
value: { bind: "auto" },
onPatch,
}),
container,
);
const tailnetButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
).find((btn) => btn.textContent?.trim() === "tailnet");
expect(tailnetButton).not.toBeUndefined();
tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
});
it("renders map fields from additionalProperties", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const schema = {
type: "object",
properties: {
slack: {
type: "object",
additionalProperties: {
type: "string",
},
},
},
};
const analysis = analyzeConfigSchema(schema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {},
unsupportedPaths: analysis.unsupportedPaths,
value: { slack: { channelA: "ok" } },
onPatch,
}),
container,
);
const removeButton = container.querySelector(
".cfg-map__item-remove",
) as HTMLButtonElement | null;
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
});
it("supports wildcard uiHints for map entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const schema = {
type: "object",
properties: {
plugins: {
type: "object",
properties: {
entries: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
},
},
},
},
},
};
const analysis = analyzeConfigSchema(schema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"plugins.entries.*.enabled": { label: "Plugin Enabled" },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { plugins: { entries: { "voice-call": { enabled: true } } } },
onPatch,
}),
container,
);
expect(container.textContent).toContain("Plugin Enabled");
});
it("flags unsupported unions", () => {
const schema = {
type: "object",
properties: {
mixed: {
anyOf: [{ type: "string" }, { type: "object", properties: {} }],
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).toContain("mixed");
});
it("supports nullable types", () => {
const schema = {
type: "object",
properties: {
note: { type: ["string", "null"] },
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("note");
});
it("ignores untyped additionalProperties schemas", () => {
const schema = {
type: "object",
properties: {
channels: {
type: "object",
properties: {
whatsapp: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
},
},
additionalProperties: {},
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("channels");
});
it("flags additionalProperties true", () => {
const schema = {
type: "object",
properties: {
extra: {
type: "object",
additionalProperties: true,
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).toContain("extra");
});
});

View File

@@ -0,0 +1,25 @@
import type { GatewayBrowserClient } from "../gateway";
import type { AgentsListResult } from "../types";
export type AgentsState = {
client: GatewayBrowserClient | null;
connected: boolean;
agentsLoading: boolean;
agentsError: string | null;
agentsList: AgentsListResult | null;
};
export async function loadAgents(state: AgentsState) {
if (!state.client || !state.connected) return;
if (state.agentsLoading) return;
state.agentsLoading = true;
state.agentsError = null;
try {
const res = (await state.client.request("agents.list", {})) as AgentsListResult | undefined;
if (res) state.agentsList = res;
} catch (err) {
state.agentsError = String(err);
} finally {
state.agentsLoading = false;
}
}

View File

@@ -0,0 +1,35 @@
import type { GatewayBrowserClient } from "../gateway";
import {
normalizeAssistantIdentity,
type AssistantIdentity,
} from "../assistant-identity";
export type AssistantIdentityState = {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
};
export async function loadAssistantIdentity(
state: AssistantIdentityState,
opts?: { sessionKey?: string },
) {
if (!state.client || !state.connected) return;
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
const params = sessionKey ? { sessionKey } : {};
try {
const res = (await state.client.request("agent.identity.get", params)) as
| Partial<AssistantIdentity>
| undefined;
if (!res) return;
const normalized = normalizeAssistantIdentity(res);
state.assistantName = normalized.name;
state.assistantAvatar = normalized.avatar;
state.assistantAgentId = normalized.agentId ?? null;
} catch {
// Ignore errors; keep last known identity.
}
}

View File

@@ -0,0 +1,76 @@
import type { ChannelsStatusSnapshot } from "../types";
import type { ChannelsState } from "./channels.types";
export type { ChannelsState };
export async function loadChannels(state: ChannelsState, probe: boolean) {
if (!state.client || !state.connected) return;
if (state.channelsLoading) return;
state.channelsLoading = true;
state.channelsError = null;
try {
const res = (await state.client.request("channels.status", {
probe,
timeoutMs: 8000,
})) as ChannelsStatusSnapshot;
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
state.channelsError = String(err);
} finally {
state.channelsLoading = false;
}
}
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.start", {
force,
timeoutMs: 30000,
})) as { message?: string; qrDataUrl?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function waitWhatsAppLogin(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.wait", {
timeoutMs: 120000,
})) as { connected?: boolean; message?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) state.whatsappLoginQrDataUrl = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function logoutWhatsApp(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
}
}

View File

@@ -0,0 +1,15 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot } from "../types";
export type ChannelsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
};

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
handleChatEvent,
type ChatEventPayload,
type ChatState,
} from "./chat";
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
client: null,
connected: true,
sessionKey: "main",
chatLoading: false,
chatMessages: [],
chatThinkingLevel: null,
chatSending: false,
chatMessage: "",
chatRunId: null,
chatStream: null,
chatStreamStartedAt: null,
lastError: null,
...overrides,
};
}
describe("handleChatEvent", () => {
it("returns null when payload is missing", () => {
const state = createState();
expect(handleChatEvent(state, undefined)).toBe(null);
});
it("returns null when sessionKey does not match", () => {
const state = createState({ sessionKey: "main" });
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "other",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe(null);
});
it("returns null for delta from another run", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Hello",
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "delta",
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
};
expect(handleChatEvent(state, payload)).toBe(null);
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Hello");
});
it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-user",
chatStream: "Working...",
chatStreamStartedAt: 123,
});
const payload: ChatEventPayload = {
runId: "run-announce",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Sub-agent findings" }],
},
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe("run-user");
expect(state.chatStream).toBe("Working...");
expect(state.chatStreamStartedAt).toBe(123);
});
it("processes final from own run and clears state", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Reply",
chatStreamStartedAt: 100,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "final",
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.chatRunId).toBe(null);
expect(state.chatStream).toBe(null);
expect(state.chatStreamStartedAt).toBe(null);
});
});

View File

@@ -0,0 +1,199 @@
import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid";
import type { ChatAttachment } from "../ui-types";
export type ChatState = {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
chatLoading: boolean;
chatMessages: unknown[];
chatThinkingLevel: string | null;
chatSending: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatRunId: string | null;
chatStream: string | null;
chatStreamStartedAt: number | null;
lastError: string | null;
};
export type ChatEventPayload = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
message?: unknown;
errorMessage?: string;
};
export async function loadChatHistory(state: ChatState) {
if (!state.client || !state.connected) return;
state.chatLoading = true;
state.lastError = null;
try {
const res = (await state.client.request("chat.history", {
sessionKey: state.sessionKey,
limit: 200,
})) as { messages?: unknown[]; thinkingLevel?: string | null };
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
state.chatThinkingLevel = res.thinkingLevel ?? null;
} catch (err) {
state.lastError = String(err);
} finally {
state.chatLoading = false;
}
}
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) return null;
return { mimeType: match[1], content: match[2] };
}
export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<boolean> {
if (!state.client || !state.connected) return false;
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) return false;
const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [
...state.chatMessages,
{
role: "user",
content: contentBlocks,
timestamp: now,
},
];
state.chatSending = true;
state.lastError = null;
const runId = generateUUID();
state.chatRunId = runId;
state.chatStream = "";
state.chatStreamStartedAt = now;
// Convert attachments to API format
const apiAttachments = hasAttachments
? attachments
.map((att) => {
const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) return null;
return {
type: "image",
mimeType: parsed.mimeType,
content: parsed.content,
};
})
.filter((a): a is NonNullable<typeof a> => a !== null)
: undefined;
try {
await state.client.request("chat.send", {
sessionKey: state.sessionKey,
message: msg,
deliver: false,
idempotencyKey: runId,
attachments: apiAttachments,
});
return true;
} catch (err) {
const error = String(err);
state.chatRunId = null;
state.chatStream = null;
state.chatStreamStartedAt = null;
state.lastError = error;
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: "Error: " + error }],
timestamp: Date.now(),
},
];
return false;
} finally {
state.chatSending = false;
}
}
export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) return false;
const runId = state.chatRunId;
try {
await state.client.request(
"chat.abort",
runId
? { sessionKey: state.sessionKey, runId }
: { sessionKey: state.sessionKey },
);
return true;
} catch (err) {
state.lastError = String(err);
return false;
}
}
export function handleChatEvent(
state: ChatState,
payload?: ChatEventPayload,
) {
if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null;
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/moltbot/moltbot/issues/1909
if (
payload.runId &&
state.chatRunId &&
payload.runId !== state.chatRunId
) {
if (payload.state === "final") return "final";
return null;
}
if (payload.state === "delta") {
const next = extractText(payload.message);
if (typeof next === "string") {
const current = state.chatStream ?? "";
if (!current || next.length >= current.length) {
state.chatStream = next;
}
}
} else if (payload.state === "final") {
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
} else if (payload.state === "aborted") {
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
} else if (payload.state === "error") {
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
state.lastError = payload.errorMessage ?? "chat error";
}
return payload.state;
}

View File

@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from "vitest";
import {
applyConfigSnapshot,
applyConfig,
runUpdate,
updateConfigFormValue,
type ConfigState,
} from "./config";
function createState(): ConfigState {
return {
client: null,
connected: false,
applySessionKey: "main",
configLoading: false,
configRaw: "",
configRawOriginal: "",
configValid: null,
configIssues: [],
configSaving: false,
configApplying: false,
updateRunning: false,
configSnapshot: null,
configSchema: null,
configSchemaVersion: null,
configSchemaLoading: false,
configUiHints: {},
configForm: null,
configFormOriginal: null,
configFormDirty: false,
configFormMode: "form",
lastError: null,
};
}
describe("applyConfigSnapshot", () => {
it("does not clobber form edits while dirty", () => {
const state = createState();
state.configFormMode = "form";
state.configFormDirty = true;
state.configForm = { gateway: { mode: "local", port: 18789 } };
state.configRaw = "{\n}\n";
applyConfigSnapshot(state, {
config: { gateway: { mode: "remote", port: 9999 } },
valid: true,
issues: [],
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
});
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
);
});
it("updates config form when clean", () => {
const state = createState();
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{}",
});
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
});
it("sets configRawOriginal when clean for change detection", () => {
const state = createState();
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: '{ "gateway": { "mode": "local" } }',
});
expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }');
expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } });
});
it("preserves configRawOriginal when dirty", () => {
const state = createState();
state.configFormDirty = true;
state.configRawOriginal = '{ "original": true }';
state.configFormOriginal = { original: true };
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: '{ "gateway": { "mode": "local" } }',
});
// Original values should be preserved when dirty
expect(state.configRawOriginal).toBe('{ "original": true }');
expect(state.configFormOriginal).toEqual({ original: true });
});
});
describe("updateConfigFormValue", () => {
it("seeds from snapshot when form is null", () => {
const state = createState();
state.configSnapshot = {
config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{}",
};
updateConfigFormValue(state, ["gateway", "port"], 18789);
expect(state.configFormDirty).toBe(true);
expect(state.configForm).toEqual({
channels: { telegram: { botToken: "t" } },
gateway: { mode: "local", port: 18789 },
});
});
it("keeps raw in sync while editing the form", () => {
const state = createState();
state.configSnapshot = {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{\n}\n",
};
updateConfigFormValue(state, ["gateway", "port"], 18789);
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
);
});
});
describe("applyConfig", () => {
it("sends config.apply with raw and session key", async () => {
const request = vi.fn().mockResolvedValue({});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
state.configFormMode = "raw";
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
state.configSnapshot = {
hash: "hash-123",
};
await applyConfig(state);
expect(request).toHaveBeenCalledWith("config.apply", {
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
baseHash: "hash-123",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
});
describe("runUpdate", () => {
it("sends update.run with session key", async () => {
const request = vi.fn().mockResolvedValue({});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
await runUpdate(state);
expect(request).toHaveBeenCalledWith("update.run", {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
});

View File

@@ -0,0 +1,202 @@
import type { GatewayBrowserClient } from "../gateway";
import type {
ConfigSchemaResponse,
ConfigSnapshot,
ConfigUiHints,
} from "../types";
import {
cloneConfigObject,
removePathValue,
serializeConfigForm,
setPathValue,
} from "./config/form-utils";
export type ConfigState = {
client: GatewayBrowserClient | null;
connected: boolean;
applySessionKey: string;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaVersion: string | null;
configSchemaLoading: boolean;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
lastError: string | null;
};
export async function loadConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configLoading = true;
state.lastError = null;
try {
const res = (await state.client.request("config.get", {})) as ConfigSnapshot;
applyConfigSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.configLoading = false;
}
}
export async function loadConfigSchema(state: ConfigState) {
if (!state.client || !state.connected) return;
if (state.configSchemaLoading) return;
state.configSchemaLoading = true;
try {
const res = (await state.client.request(
"config.schema",
{},
)) as ConfigSchemaResponse;
applyConfigSchema(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.configSchemaLoading = false;
}
}
export function applyConfigSchema(
state: ConfigState,
res: ConfigSchemaResponse,
) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
}
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
state.configSnapshot = snapshot;
const rawFromSnapshot =
typeof snapshot.raw === "string"
? snapshot.raw
: snapshot.config && typeof snapshot.config === "object"
? serializeConfigForm(snapshot.config as Record<string, unknown>)
: state.configRaw;
if (!state.configFormDirty || state.configFormMode === "raw") {
state.configRaw = rawFromSnapshot;
} else if (state.configForm) {
state.configRaw = serializeConfigForm(state.configForm);
} else {
state.configRaw = rawFromSnapshot;
}
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {});
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
state.configRawOriginal = rawFromSnapshot;
}
}
export async function saveConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configSaving = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return;
}
await state.client.request("config.set", { raw, baseHash });
state.configFormDirty = false;
await loadConfig(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.configSaving = false;
}
}
export async function applyConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configApplying = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return;
}
await state.client.request("config.apply", {
raw,
baseHash,
sessionKey: state.applySessionKey,
});
state.configFormDirty = false;
await loadConfig(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.configApplying = false;
}
}
export async function runUpdate(state: ConfigState) {
if (!state.client || !state.connected) return;
state.updateRunning = true;
state.lastError = null;
try {
await state.client.request("update.run", {
sessionKey: state.applySessionKey,
});
} catch (err) {
state.lastError = String(err);
} finally {
state.updateRunning = false;
}
}
export function updateConfigFormValue(
state: ConfigState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}
export function removeConfigFormValue(
state: ConfigState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}

View File

@@ -0,0 +1,76 @@
export function cloneConfigObject<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export function serializeConfigForm(form: Record<string, unknown>): string {
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
}
export function setPathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
value: unknown,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const nextKey = path[i + 1];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) current[lastKey] = value;
return;
}
if (typeof current === "object" && current != null) {
(current as Record<string, unknown>)[lastKey] = value;
}
}
export function removePathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
current = (current as Record<string, unknown>)[key] as
| Record<string, unknown>
| unknown[];
}
if (current == null) return;
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) current.splice(lastKey, 1);
return;
}
if (typeof current === "object" && current != null) {
delete (current as Record<string, unknown>)[lastKey];
}
}

View File

@@ -0,0 +1,192 @@
import { toNumber } from "../format";
import type { GatewayBrowserClient } from "../gateway";
import type { CronJob, CronRunLogEntry, CronStatus } from "../types";
import type { CronFormState } from "../ui-types";
export type CronState = {
client: GatewayBrowserClient | null;
connected: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronRunsJobId: string | null;
cronRuns: CronRunLogEntry[];
cronBusy: boolean;
};
export async function loadCronStatus(state: CronState) {
if (!state.client || !state.connected) return;
try {
const res = (await state.client.request("cron.status", {})) as CronStatus;
state.cronStatus = res;
} catch (err) {
state.cronError = String(err);
}
}
export async function loadCronJobs(state: CronState) {
if (!state.client || !state.connected) return;
if (state.cronLoading) return;
state.cronLoading = true;
state.cronError = null;
try {
const res = (await state.client.request("cron.list", {
includeDisabled: true,
})) as { jobs?: CronJob[] };
state.cronJobs = Array.isArray(res.jobs) ? res.jobs : [];
} catch (err) {
state.cronError = String(err);
} finally {
state.cronLoading = false;
}
}
export function buildCronSchedule(form: CronFormState) {
if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) throw new Error("Invalid run time.");
return { kind: "at" as const, atMs: ms };
}
if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) throw new Error("Invalid interval amount.");
const unit = form.everyUnit;
const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
return { kind: "every" as const, everyMs: amount * mult };
}
const expr = form.cronExpr.trim();
if (!expr) throw new Error("Cron expression required.");
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
}
export function buildCronPayload(form: CronFormState) {
if (form.payloadKind === "systemEvent") {
const text = form.payloadText.trim();
if (!text) throw new Error("System event text required.");
return { kind: "systemEvent" as const, text };
}
const message = form.payloadText.trim();
if (!message) throw new Error("Agent message required.");
const payload: {
kind: "agentTurn";
message: string;
deliver?: boolean;
channel?: string;
to?: string;
timeoutSeconds?: number;
} = { kind: "agentTurn", message };
if (form.deliver) payload.deliver = true;
if (form.channel) payload.channel = form.channel;
if (form.to.trim()) payload.to = form.to.trim();
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds;
return payload;
}
export async function addCronJob(state: CronState) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;
try {
const schedule = buildCronSchedule(state.cronForm);
const payload = buildCronPayload(state.cronForm);
const agentId = state.cronForm.agentId.trim();
const job = {
name: state.cronForm.name.trim(),
description: state.cronForm.description.trim() || undefined,
agentId: agentId || undefined,
enabled: state.cronForm.enabled,
schedule,
sessionTarget: state.cronForm.sessionTarget,
wakeMode: state.cronForm.wakeMode,
payload,
isolation:
state.cronForm.postToMainPrefix.trim() &&
state.cronForm.sessionTarget === "isolated"
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
: undefined,
};
if (!job.name) throw new Error("Name required.");
await state.client.request("cron.add", job);
state.cronForm = {
...state.cronForm,
name: "",
description: "",
payloadText: "",
};
await loadCronJobs(state);
await loadCronStatus(state);
} catch (err) {
state.cronError = String(err);
} finally {
state.cronBusy = false;
}
}
export async function toggleCronJob(
state: CronState,
job: CronJob,
enabled: boolean,
) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;
try {
await state.client.request("cron.update", { id: job.id, patch: { enabled } });
await loadCronJobs(state);
await loadCronStatus(state);
} catch (err) {
state.cronError = String(err);
} finally {
state.cronBusy = false;
}
}
export async function runCronJob(state: CronState, job: CronJob) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;
try {
await state.client.request("cron.run", { id: job.id, mode: "force" });
await loadCronRuns(state, job.id);
} catch (err) {
state.cronError = String(err);
} finally {
state.cronBusy = false;
}
}
export async function removeCronJob(state: CronState, job: CronJob) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;
try {
await state.client.request("cron.remove", { id: job.id });
if (state.cronRunsJobId === job.id) {
state.cronRunsJobId = null;
state.cronRuns = [];
}
await loadCronJobs(state);
await loadCronStatus(state);
} catch (err) {
state.cronError = String(err);
} finally {
state.cronBusy = false;
}
}
export async function loadCronRuns(state: CronState, jobId: string) {
if (!state.client || !state.connected) return;
try {
const res = (await state.client.request("cron.runs", {
id: jobId,
limit: 50,
})) as { entries?: CronRunLogEntry[] };
state.cronRunsJobId = jobId;
state.cronRuns = Array.isArray(res.entries) ? res.entries : [];
} catch (err) {
state.cronError = String(err);
}
}

View File

@@ -0,0 +1,56 @@
import type { GatewayBrowserClient } from "../gateway";
import type { HealthSnapshot, StatusSummary } from "../types";
export type DebugState = {
client: GatewayBrowserClient | null;
connected: boolean;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown | null;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
};
export async function loadDebug(state: DebugState) {
if (!state.client || !state.connected) return;
if (state.debugLoading) return;
state.debugLoading = true;
try {
const [status, health, models, heartbeat] = await Promise.all([
state.client.request("status", {}),
state.client.request("health", {}),
state.client.request("models.list", {}),
state.client.request("last-heartbeat", {}),
]);
state.debugStatus = status as StatusSummary;
state.debugHealth = health as HealthSnapshot;
const modelPayload = models as { models?: unknown[] } | undefined;
state.debugModels = Array.isArray(modelPayload?.models)
? modelPayload?.models
: [];
state.debugHeartbeat = heartbeat as unknown;
} catch (err) {
state.debugCallError = String(err);
} finally {
state.debugLoading = false;
}
}
export async function callDebugMethod(state: DebugState) {
if (!state.client || !state.connected) return;
state.debugCallError = null;
state.debugCallResult = null;
try {
const params = state.debugCallParams.trim()
? (JSON.parse(state.debugCallParams) as unknown)
: {};
const res = await state.client.request(state.debugCallMethod.trim(), params);
state.debugCallResult = JSON.stringify(res, null, 2);
} catch (err) {
state.debugCallError = String(err);
}
}

View File

@@ -0,0 +1,135 @@
import type { GatewayBrowserClient } from "../gateway";
import { loadOrCreateDeviceIdentity } from "../device-identity";
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth";
export type DeviceTokenSummary = {
role: string;
scopes?: string[];
createdAtMs?: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
export type PendingDevice = {
requestId: string;
deviceId: string;
displayName?: string;
role?: string;
remoteIp?: string;
isRepair?: boolean;
ts?: number;
};
export type PairedDevice = {
deviceId: string;
displayName?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
tokens?: DeviceTokenSummary[];
createdAtMs?: number;
approvedAtMs?: number;
};
export type DevicePairingList = {
pending: PendingDevice[];
paired: PairedDevice[];
};
export type DevicesState = {
client: GatewayBrowserClient | null;
connected: boolean;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
};
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.devicesLoading) return;
state.devicesLoading = true;
if (!opts?.quiet) state.devicesError = null;
try {
const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null;
state.devicesList = {
pending: Array.isArray(res?.pending) ? res!.pending : [],
paired: Array.isArray(res?.paired) ? res!.paired : [],
};
} catch (err) {
if (!opts?.quiet) state.devicesError = String(err);
} finally {
state.devicesLoading = false;
}
}
export async function approveDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return;
try {
await state.client.request("device.pair.approve", { requestId });
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm("Reject this device pairing request?");
if (!confirmed) return;
try {
await state.client.request("device.pair.reject", { requestId });
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function rotateDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string; scopes?: string[] },
) {
if (!state.client || !state.connected) return;
try {
const res = (await state.client.request("device.token.rotate", params)) as
| { token?: string; role?: string; deviceId?: string; scopes?: string[] }
| undefined;
if (res?.token) {
const identity = await loadOrCreateDeviceIdentity();
const role = res.role ?? params.role;
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
storeDeviceAuthToken({
deviceId: identity.deviceId,
role,
token: res.token,
scopes: res.scopes ?? params.scopes ?? [],
});
}
window.prompt("New device token (copy and store securely):", res.token);
}
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}
export async function revokeDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string },
) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm(
`Revoke token for ${params.deviceId} (${params.role})?`,
);
if (!confirmed) return;
try {
await state.client.request("device.token.revoke", params);
const identity = await loadOrCreateDeviceIdentity();
if (params.deviceId === identity.deviceId) {
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
}
await loadDevices(state);
} catch (err) {
state.devicesError = String(err);
}
}

View File

@@ -0,0 +1,85 @@
export type ExecApprovalRequestPayload = {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
export type ExecApprovalRequest = {
id: string;
request: ExecApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision?: string | null;
resolvedBy?: string | null;
ts?: number | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
if (!isRecord(payload)) return null;
const id = typeof payload.id === "string" ? payload.id.trim() : "";
const request = payload.request;
if (!id || !isRecord(request)) return null;
const command = typeof request.command === "string" ? request.command.trim() : "";
if (!command) return null;
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
if (!createdAtMs || !expiresAtMs) return null;
return {
id,
request: {
command,
cwd: typeof request.cwd === "string" ? request.cwd : null,
host: typeof request.host === "string" ? request.host : null,
security: typeof request.security === "string" ? request.security : null,
ask: typeof request.ask === "string" ? request.ask : null,
agentId: typeof request.agentId === "string" ? request.agentId : null,
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
},
createdAtMs,
expiresAtMs,
};
}
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
if (!isRecord(payload)) return null;
const id = typeof payload.id === "string" ? payload.id.trim() : "";
if (!id) return null;
return {
id,
decision: typeof payload.decision === "string" ? payload.decision : null,
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
ts: typeof payload.ts === "number" ? payload.ts : null,
};
}
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
const now = Date.now();
return queue.filter((entry) => entry.expiresAtMs > now);
}
export function addExecApproval(
queue: ExecApprovalRequest[],
entry: ExecApprovalRequest,
): ExecApprovalRequest[] {
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
next.push(entry);
return next;
}
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
}

View File

@@ -0,0 +1,165 @@
import type { GatewayBrowserClient } from "../gateway";
import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils";
export type ExecApprovalsDefaults = {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
export type ExecApprovalsAllowlistEntry = {
id?: string;
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
allowlist?: ExecApprovalsAllowlistEntry[];
};
export type ExecApprovalsFile = {
version?: number;
socket?: { path?: string };
defaults?: ExecApprovalsDefaults;
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
connected: boolean;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
lastError: string | null;
};
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
method: string;
params: Record<string, unknown>;
} | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.get", params: {} };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.get", params: { nodeId } };
}
function resolveExecApprovalsSaveRpc(
target: ExecApprovalsTarget | null | undefined,
params: { file: ExecApprovalsFile; baseHash: string },
): { method: string; params: Record<string, unknown> } | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.set", params };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
}
export async function loadExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return;
if (state.execApprovalsLoading) return;
state.execApprovalsLoading = true;
state.lastError = null;
try {
const rpc = resolveExecApprovalsRpc(target);
if (!rpc) {
state.lastError = "Select a node before loading exec approvals.";
return;
}
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsLoading = false;
}
}
export function applyExecApprovalsSnapshot(
state: ExecApprovalsState,
snapshot: ExecApprovalsSnapshot,
) {
state.execApprovalsSnapshot = snapshot;
if (!state.execApprovalsDirty) {
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
}
}
export async function saveExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return;
state.execApprovalsSaving = true;
state.lastError = null;
try {
const baseHash = state.execApprovalsSnapshot?.hash;
if (!baseHash) {
state.lastError = "Exec approvals hash missing; reload and retry.";
return;
}
const file =
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
if (!rpc) {
state.lastError = "Select a node before saving exec approvals.";
return;
}
await state.client.request(rpc.method, rpc.params);
state.execApprovalsDirty = false;
await loadExecApprovals(state, target);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsSaving = false;
}
}
export function updateExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
setPathValue(base, path, value);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}
export function removeExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
removePathValue(base, path);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}

View File

@@ -0,0 +1,136 @@
import type { GatewayBrowserClient } from "../gateway";
import type { LogEntry, LogLevel } from "../types";
export type LogsState = {
client: GatewayBrowserClient | null;
connected: boolean;
logsLoading: boolean;
logsError: string | null;
logsCursor: number | null;
logsFile: string | null;
logsEntries: LogEntry[];
logsTruncated: boolean;
logsLastFetchAt: number | null;
logsLimit: number;
logsMaxBytes: number;
};
const LOG_BUFFER_LIMIT = 2000;
const LEVELS = new Set<LogLevel>([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
]);
function parseMaybeJsonString(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function normalizeLevel(value: unknown): LogLevel | null {
if (typeof value !== "string") return null;
const lowered = value.toLowerCase() as LogLevel;
return LEVELS.has(lowered) ? lowered : null;
}
export function parseLogLine(line: string): LogEntry {
if (!line.trim()) return { raw: line, message: line };
try {
const obj = JSON.parse(line) as Record<string, unknown>;
const meta =
obj && typeof obj._meta === "object" && obj._meta !== null
? (obj._meta as Record<string, unknown>)
: null;
const time =
typeof obj.time === "string"
? obj.time
: typeof meta?.date === "string"
? meta?.date
: null;
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
const contextCandidate =
typeof obj["0"] === "string"
? (obj["0"] as string)
: typeof meta?.name === "string"
? (meta?.name as string)
: null;
const contextObj = parseMaybeJsonString(contextCandidate);
let subsystem: string | null = null;
if (contextObj) {
if (typeof contextObj.subsystem === "string") subsystem = contextObj.subsystem;
else if (typeof contextObj.module === "string") subsystem = contextObj.module;
}
if (!subsystem && contextCandidate && contextCandidate.length < 120) {
subsystem = contextCandidate;
}
let message: string | null = null;
if (typeof obj["1"] === "string") message = obj["1"] as string;
else if (!contextObj && typeof obj["0"] === "string") message = obj["0"] as string;
else if (typeof obj.message === "string") message = obj.message as string;
return {
raw: line,
time,
level,
subsystem,
message: message ?? line,
meta: meta ?? undefined,
};
} catch {
return { raw: line, message: line };
}
}
export async function loadLogs(
state: LogsState,
opts?: { reset?: boolean; quiet?: boolean },
) {
if (!state.client || !state.connected) return;
if (state.logsLoading && !opts?.quiet) return;
if (!opts?.quiet) state.logsLoading = true;
state.logsError = null;
try {
const res = await state.client.request("logs.tail", {
cursor: opts?.reset ? undefined : state.logsCursor ?? undefined,
limit: state.logsLimit,
maxBytes: state.logsMaxBytes,
});
const payload = res as {
file?: string;
cursor?: number;
size?: number;
lines?: unknown;
truncated?: boolean;
reset?: boolean;
};
const lines = Array.isArray(payload.lines)
? (payload.lines.filter((line) => typeof line === "string") as string[])
: [];
const entries = lines.map(parseLogLine);
const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null);
state.logsEntries = shouldReset
? entries
: [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
if (typeof payload.cursor === "number") state.logsCursor = payload.cursor;
if (typeof payload.file === "string") state.logsFile = payload.file;
state.logsTruncated = Boolean(payload.truncated);
state.logsLastFetchAt = Date.now();
} catch (err) {
state.logsError = String(err);
} finally {
if (!opts?.quiet) state.logsLoading = false;
}
}

View File

@@ -0,0 +1,29 @@
import type { GatewayBrowserClient } from "../gateway";
export type NodesState = {
client: GatewayBrowserClient | null;
connected: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
lastError: string | null;
};
export async function loadNodes(
state: NodesState,
opts?: { quiet?: boolean },
) {
if (!state.client || !state.connected) return;
if (state.nodesLoading) return;
state.nodesLoading = true;
if (!opts?.quiet) state.lastError = null;
try {
const res = (await state.client.request("node.list", {})) as {
nodes?: Array<Record<string, unknown>>;
};
state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
} catch (err) {
if (!opts?.quiet) state.lastError = String(err);
} finally {
state.nodesLoading = false;
}
}

View File

@@ -0,0 +1,35 @@
import type { GatewayBrowserClient } from "../gateway";
import type { PresenceEntry } from "../types";
export type PresenceState = {
client: GatewayBrowserClient | null;
connected: boolean;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: string | null;
};
export async function loadPresence(state: PresenceState) {
if (!state.client || !state.connected) return;
if (state.presenceLoading) return;
state.presenceLoading = true;
state.presenceError = null;
state.presenceStatus = null;
try {
const res = (await state.client.request("system-presence", {})) as
| PresenceEntry[]
| undefined;
if (Array.isArray(res)) {
state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null;
} else {
state.presenceEntries = [];
state.presenceStatus = "No presence payload.";
}
} catch (err) {
state.presenceError = String(err);
} finally {
state.presenceLoading = false;
}
}

View File

@@ -0,0 +1,83 @@
import type { GatewayBrowserClient } from "../gateway";
import { toNumber } from "../format";
import type { SessionsListResult } from "../types";
export type SessionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
sessionsFilterActive: string;
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
};
export async function loadSessions(state: SessionsState) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
state.sessionsLoading = true;
state.sessionsError = null;
try {
const params: Record<string, unknown> = {
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
};
const activeMinutes = toNumber(state.sessionsFilterActive, 0);
const limit = toNumber(state.sessionsFilterLimit, 0);
if (activeMinutes > 0) params.activeMinutes = activeMinutes;
if (limit > 0) params.limit = limit;
const res = (await state.client.request("sessions.list", params)) as
| SessionsListResult
| undefined;
if (res) state.sessionsResult = res;
} catch (err) {
state.sessionsError = String(err);
} finally {
state.sessionsLoading = false;
}
}
export async function patchSession(
state: SessionsState,
key: string,
patch: {
label?: string | null;
thinkingLevel?: string | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
},
) {
if (!state.client || !state.connected) return;
const params: Record<string, unknown> = { key };
if ("label" in patch) params.label = patch.label;
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;
try {
await state.client.request("sessions.patch", params);
await loadSessions(state);
} catch (err) {
state.sessionsError = String(err);
}
}
export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
const confirmed = window.confirm(
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
);
if (!confirmed) return;
state.sessionsLoading = true;
state.sessionsError = null;
try {
await state.client.request("sessions.delete", { key, deleteTranscript: true });
await loadSessions(state);
} catch (err) {
state.sessionsError = String(err);
} finally {
state.sessionsLoading = false;
}
}

View File

@@ -0,0 +1,148 @@
import type { GatewayBrowserClient } from "../gateway";
import type { SkillStatusReport } from "../types";
export type SkillsState = {
client: GatewayBrowserClient | null;
connected: boolean;
skillsLoading: boolean;
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsBusyKey: string | null;
skillEdits: Record<string, string>;
skillMessages: SkillMessageMap;
};
export type SkillMessage = {
kind: "success" | "error";
message: string;
};
export type SkillMessageMap = Record<string, SkillMessage>;
type LoadSkillsOptions = {
clearMessages?: boolean;
};
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
if (!key.trim()) return;
const next = { ...state.skillMessages };
if (message) next[key] = message;
else delete next[key];
state.skillMessages = next;
}
function getErrorMessage(err: unknown) {
if (err instanceof Error) return err.message;
return String(err);
}
export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) {
if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
state.skillMessages = {};
}
if (!state.client || !state.connected) return;
if (state.skillsLoading) return;
state.skillsLoading = true;
state.skillsError = null;
try {
const res = (await state.client.request("skills.status", {})) as
| SkillStatusReport
| undefined;
if (res) state.skillsReport = res;
} catch (err) {
state.skillsError = getErrorMessage(err);
} finally {
state.skillsLoading = false;
}
}
export function updateSkillEdit(
state: SkillsState,
skillKey: string,
value: string,
) {
state.skillEdits = { ...state.skillEdits, [skillKey]: value };
}
export async function updateSkillEnabled(
state: SkillsState,
skillKey: string,
enabled: boolean,
) {
if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey;
state.skillsError = null;
try {
await state.client.request("skills.update", { skillKey, enabled });
await loadSkills(state);
setSkillMessage(state, skillKey, {
kind: "success",
message: enabled ? "Skill enabled" : "Skill disabled",
});
} catch (err) {
const message = getErrorMessage(err);
state.skillsError = message;
setSkillMessage(state, skillKey, {
kind: "error",
message,
});
} finally {
state.skillsBusyKey = null;
}
}
export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey;
state.skillsError = null;
try {
const apiKey = state.skillEdits[skillKey] ?? "";
await state.client.request("skills.update", { skillKey, apiKey });
await loadSkills(state);
setSkillMessage(state, skillKey, {
kind: "success",
message: "API key saved",
});
} catch (err) {
const message = getErrorMessage(err);
state.skillsError = message;
setSkillMessage(state, skillKey, {
kind: "error",
message,
});
} finally {
state.skillsBusyKey = null;
}
}
export async function installSkill(
state: SkillsState,
skillKey: string,
name: string,
installId: string,
) {
if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey;
state.skillsError = null;
try {
const result = (await state.client.request("skills.install", {
name,
installId,
timeoutMs: 120000,
})) as { ok?: boolean; message?: string };
await loadSkills(state);
setSkillMessage(state, skillKey, {
kind: "success",
message: result?.message ?? "Installed",
});
} catch (err) {
const message = getErrorMessage(err);
state.skillsError = message;
setSkillMessage(state, skillKey, {
kind: "error",
message,
});
} finally {
state.skillsBusyKey = null;
}
}

View File

@@ -0,0 +1,99 @@
export type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const STORAGE_KEY = "moltbot.device.auth.v1";
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readStore(): DeviceAuthStore | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeStore(store: DeviceAuthStore) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// best-effort
}
}
export function loadDeviceAuthToken(params: {
deviceId: string;
role: string;
}): DeviceAuthEntry | null {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
export function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeRole(params.role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens: {},
};
const existing = readStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[role] = entry;
writeStore(next);
return entry;
}
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
const store = readStore();
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
if (!store.tokens[role]) return;
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[role];
writeStore(next);
}

View File

@@ -0,0 +1,108 @@
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
type StoredIdentity = {
version: 1;
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
export type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
};
const STORAGE_KEY = "moltbot-device-identity-v1";
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Uint8Array {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", publicKey);
return bytesToHex(new Uint8Array(hash));
}
async function generateIdentity(): Promise<DeviceIdentity> {
const privateKey = utils.randomSecretKey();
const publicKey = await getPublicKeyAsync(privateKey);
const deviceId = await fingerprintPublicKey(publicKey);
return {
deviceId,
publicKey: base64UrlEncode(publicKey),
privateKey: base64UrlEncode(privateKey),
};
}
export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.privateKey === "string"
) {
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
if (derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return {
deviceId: derivedId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
return {
deviceId: parsed.deviceId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
}
} catch {
// fall through to regenerate
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
return identity;
}
export async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
const key = base64UrlDecode(privateKeyBase64Url);
const data = new TextEncoder().encode(payload);
const sig = await signAsync(data, key);
return base64UrlEncode(sig);
}

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MoltbotApp } from "./app";
const originalConnect = MoltbotApp.prototype.connect;
function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
const app = document.createElement("moltbot-app") as MoltbotApp;
document.body.append(app);
return app;
}
beforeEach(() => {
MoltbotApp.prototype.connect = () => {
// no-op: avoid real gateway WS connections in browser tests
};
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
afterEach(() => {
MoltbotApp.prototype.connect = originalConnect;
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
describe("chat focus mode", () => {
it("collapses header + sidebar on chat tab only", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const shell = app.querySelector(".shell");
expect(shell).not.toBeNull();
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const toggle = app.querySelector<HTMLButtonElement>(
'button[title^="Toggle focus mode"]',
);
expect(toggle).not.toBeNull();
toggle?.click();
await app.updateComplete;
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
await app.updateComplete;
expect(app.tab).toBe("channels");
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');
chatLink?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
await app.updateComplete;
expect(app.tab).toBe("chat");
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { stripThinkingTags } from "./format";
describe("stripThinkingTags", () => {
it("strips <think>…</think> segments", () => {
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n");
expect(stripThinkingTags(input)).toBe("Hello");
});
it("strips <thinking>…</thinking> segments", () => {
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n");
expect(stripThinkingTags(input)).toBe("Hello");
});
it("keeps text when tags are unpaired", () => {
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello");
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
});
it("returns original text when no tags exist", () => {
expect(stripThinkingTags("Hello")).toBe("Hello");
});
it("strips <final>…</final> segments", () => {
const input = "<final>\n\nHello there\n\n</final>";
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
});
it("strips mixed <think> and <final> tags", () => {
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
expect(stripThinkingTags(input)).toBe("Hello");
});
it("handles incomplete <final tag gracefully", () => {
// When streaming splits mid-tag, we may see "<final" without closing ">"
// This should not crash and should handle gracefully
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
});
});

View File

@@ -0,0 +1,74 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
return new Date(ms).toLocaleString();
}
export function formatAgo(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
const diff = Date.now() - ms;
if (diff < 0) return "just now";
const sec = Math.round(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 48) return `${hr}h ago`;
const day = Math.round(hr / 24);
return `${day}d ago`;
}
export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) return "n/a";
if (ms < 1000) return `${ms}ms`;
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
if (hr < 48) return `${hr}h`;
const day = Math.round(hr / 24);
return `${day}d`;
}
export function formatList(values?: Array<string | null | undefined>): string {
if (!values || values.length === 0) return "none";
return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
}
export function clampText(value: string, max = 120): string {
if (value.length <= max) return value;
return `${value.slice(0, Math.max(0, max - 1))}`;
}
export function truncateText(value: string, max: number): {
text: string;
truncated: boolean;
total: number;
} {
if (value.length <= max) {
return { text: value, truncated: false, total: value.length };
}
return {
text: value.slice(0, Math.max(0, max)),
truncated: true,
total: value.length,
};
}
export function toNumber(value: string, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function parseList(input: string): string[] {
return input
.split(/[,\n]/)
.map((v) => v.trim())
.filter((v) => v.length > 0);
}
export function stripThinkingTags(value: string): string {
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
}

View File

@@ -0,0 +1,297 @@
import { generateUUID } from "./uuid";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js";
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth";
export type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
export type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
};
export type GatewayBrowserClientOptions = {
url: string;
token?: string;
password?: string;
clientName?: GatewayClientName;
clientVersion?: string;
platform?: string;
mode?: GatewayClientMode;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
const CONNECT_FAILED_CLOSE_CODE = 4008;
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this.closed = false;
this.connect();
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
};
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
}
private flushPending(err: Error) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private async sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Over plain HTTP, we skip device identity and fall back to token-only auth.
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
token: authToken,
password: this.opts.password,
}
: undefined;
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role,
scopes,
device,
caps: [],
auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch(() => {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
});
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
try {
this.opts.onEvent?.(evt);
} catch (err) {
console.error("[gateway] event handler error:", err);
}
return;
}
if (frame.type === "res") {
const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id);
if (!pending) return;
this.pending.delete(res.id);
if (res.ok) pending.resolve(res.payload);
else pending.reject(new Error(res.error?.message ?? "request failed"));
return;
}
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected"));
}
const id = generateUUID();
const frame = { type: "req", id, method, params };
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this.ws.send(JSON.stringify(frame));
return p;
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
}
}

View File

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

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { toSanitizedMarkdownHtml } from "./markdown";
describe("toSanitizedMarkdownHtml", () => {
it("renders basic markdown", () => {
const html = toSanitizedMarkdownHtml("Hello **world**");
expect(html).toContain("<strong>world</strong>");
});
it("strips scripts and unsafe links", () => {
const html = toSanitizedMarkdownHtml(
[
"<script>alert(1)</script>",
"",
"[x](javascript:alert(1))",
"",
"[ok](https://example.com)",
].join("\n"),
);
expect(html).not.toContain("<script");
expect(html).not.toContain("javascript:");
expect(html).toContain("https://example.com");
});
it("renders fenced code blocks", () => {
const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n"));
expect(html).toContain("<pre>");
expect(html).toContain("<code");
expect(html).toContain("console.log(1)");
});
});

View File

@@ -0,0 +1,118 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { truncateText } from "./format";
marked.setOptions({
gfm: true,
breaks: true,
mangle: false,
});
const allowedTags = [
"a",
"b",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"i",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
];
const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
let hooksInstalled = false;
const MARKDOWN_CHAR_LIMIT = 140_000;
const MARKDOWN_PARSE_LIMIT = 40_000;
const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const markdownCache = new Map<string, string>();
function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key);
if (cached === undefined) return null;
markdownCache.delete(key);
markdownCache.set(key, cached);
return cached;
}
function setCachedMarkdown(key: string, value: string) {
markdownCache.set(key, value);
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) return;
const oldest = markdownCache.keys().next().value;
if (oldest) markdownCache.delete(oldest);
}
function installHooks() {
if (hooksInstalled) return;
hooksInstalled = true;
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
const href = node.getAttribute("href");
if (!href) return;
node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank");
});
}
export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim();
if (!input) return "";
installHooks();
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
const cached = getCachedMarkdown(input);
if (cached !== null) return cached;
}
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
: "";
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
const escaped = escapeHtml(`${truncated.text}${suffix}`);
const html = `<pre class="code-block">${escaped}</pre>`;
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
}
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
const sanitized = DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(input, sanitized);
}
return sanitized;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MoltbotApp } from "./app";
import "../styles.css";
const originalConnect = MoltbotApp.prototype.connect;
function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
const app = document.createElement("moltbot-app") as MoltbotApp;
document.body.append(app);
return app;
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
beforeEach(() => {
MoltbotApp.prototype.connect = () => {
// no-op: avoid real gateway WS connections in browser tests
};
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
afterEach(() => {
MoltbotApp.prototype.connect = originalConnect;
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
localStorage.clear();
document.body.innerHTML = "";
});
describe("control UI routing", () => {
it("hydrates the tab from the location", async () => {
const app = mountApp("/sessions");
await app.updateComplete;
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/sessions");
});
it("respects /ui base paths", async () => {
const app = mountApp("/ui/cron");
await app.updateComplete;
expect(app.basePath).toBe("/ui");
expect(app.tab).toBe("cron");
expect(window.location.pathname).toBe("/ui/cron");
});
it("infers nested base paths", async () => {
const app = mountApp("/apps/moltbot/cron");
await app.updateComplete;
expect(app.basePath).toBe("/apps/moltbot");
expect(app.tab).toBe("cron");
expect(window.location.pathname).toBe("/apps/moltbot/cron");
});
it("honors explicit base path overrides", async () => {
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = "/moltbot";
const app = mountApp("/moltbot/sessions");
await app.updateComplete;
expect(app.basePath).toBe("/moltbot");
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/moltbot/sessions");
});
it("updates the URL when clicking nav items", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>(
'a.nav-item[href="/channels"]',
);
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
await app.updateComplete;
expect(app.tab).toBe("channels");
expect(window.location.pathname).toBe("/channels");
});
it("keeps chat and nav usable on narrow viewports", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const split = app.querySelector(".chat-split-container") as HTMLElement | null;
expect(split).not.toBeNull();
if (split) {
expect(getComputedStyle(split).position).not.toBe("fixed");
}
const chatMain = app.querySelector(".chat-main") as HTMLElement | null;
expect(chatMain).not.toBeNull();
if (chatMain) {
expect(getComputedStyle(chatMain).display).not.toBe("none");
}
if (split) {
split.classList.add("chat-split-container--open");
await app.updateComplete;
expect(getComputedStyle(split).position).toBe("fixed");
}
if (chatMain) {
expect(getComputedStyle(chatMain).display).toBe("none");
}
});
it("auto-scrolls chat history to the latest message", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const initialContainer = app.querySelector(".chat-thread") as HTMLElement | null;
expect(initialContainer).not.toBeNull();
if (!initialContainer) return;
initialContainer.style.maxHeight = "180px";
initialContainer.style.overflow = "auto";
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
role: "assistant",
content: `Line ${index} - ${"x".repeat(200)}`,
timestamp: Date.now() + index,
}));
await app.updateComplete;
for (let i = 0; i < 6; i++) {
await nextFrame();
}
const container = app.querySelector(".chat-thread") as HTMLElement | null;
expect(container).not.toBeNull();
if (!container) return;
const maxScroll = container.scrollHeight - container.clientHeight;
expect(maxScroll).toBeGreaterThan(0);
for (let i = 0; i < 10; i++) {
if (container.scrollTop === maxScroll) break;
await nextFrame();
}
expect(container.scrollTop).toBe(maxScroll);
});
it("hydrates token from URL params and strips it", async () => {
const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
it("hydrates password from URL params and strips it", async () => {
const app = mountApp("/ui/overview?password=sekret");
await app.updateComplete;
expect(app.password).toBe("sekret");
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem(
"moltbot.control.settings.v1",
JSON.stringify({ token: "existing-token" }),
);
const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("abc123");
expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe("");
});
});

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import {
TAB_GROUPS,
iconForTab,
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
pathForTab,
subtitleForTab,
tabFromPath,
titleForTab,
type Tab,
} from "./navigation";
/** All valid tab identifiers derived from TAB_GROUPS */
const ALL_TABS: Tab[] = TAB_GROUPS.flatMap((group) => group.tabs) as Tab[];
describe("iconForTab", () => {
it("returns a non-empty string for every tab", () => {
for (const tab of ALL_TABS) {
const icon = iconForTab(tab);
expect(icon).toBeTruthy();
expect(typeof icon).toBe("string");
expect(icon.length).toBeGreaterThan(0);
}
});
it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬");
expect(iconForTab("overview")).toBe("📊");
expect(iconForTab("channels")).toBe("🔗");
expect(iconForTab("instances")).toBe("📡");
expect(iconForTab("sessions")).toBe("📄");
expect(iconForTab("cron")).toBe("⏰");
expect(iconForTab("skills")).toBe("⚡️");
expect(iconForTab("nodes")).toBe("🖥️");
expect(iconForTab("config")).toBe("⚙️");
expect(iconForTab("debug")).toBe("🐞");
expect(iconForTab("logs")).toBe("🧾");
});
it("returns a fallback icon for unknown tab", () => {
// TypeScript won't allow this normally, but runtime could receive unexpected values
const unknownTab = "unknown" as Tab;
expect(iconForTab(unknownTab)).toBe("📁");
});
});
describe("titleForTab", () => {
it("returns a non-empty string for every tab", () => {
for (const tab of ALL_TABS) {
const title = titleForTab(tab);
expect(title).toBeTruthy();
expect(typeof title).toBe("string");
}
});
it("returns expected titles", () => {
expect(titleForTab("chat")).toBe("Chat");
expect(titleForTab("overview")).toBe("Overview");
expect(titleForTab("cron")).toBe("Cron Jobs");
});
});
describe("subtitleForTab", () => {
it("returns a string for every tab", () => {
for (const tab of ALL_TABS) {
const subtitle = subtitleForTab(tab);
expect(typeof subtitle).toBe("string");
}
});
it("returns descriptive subtitles", () => {
expect(subtitleForTab("chat")).toContain("chat session");
expect(subtitleForTab("config")).toContain("moltbot.json");
});
});
describe("normalizeBasePath", () => {
it("returns empty string for falsy input", () => {
expect(normalizeBasePath("")).toBe("");
});
it("adds leading slash if missing", () => {
expect(normalizeBasePath("ui")).toBe("/ui");
});
it("removes trailing slash", () => {
expect(normalizeBasePath("/ui/")).toBe("/ui");
});
it("returns empty string for root path", () => {
expect(normalizeBasePath("/")).toBe("");
});
it("handles nested paths", () => {
expect(normalizeBasePath("/apps/moltbot")).toBe("/apps/moltbot");
});
});
describe("normalizePath", () => {
it("returns / for falsy input", () => {
expect(normalizePath("")).toBe("/");
});
it("adds leading slash if missing", () => {
expect(normalizePath("chat")).toBe("/chat");
});
it("removes trailing slash except for root", () => {
expect(normalizePath("/chat/")).toBe("/chat");
expect(normalizePath("/")).toBe("/");
});
});
describe("pathForTab", () => {
it("returns correct path without base", () => {
expect(pathForTab("chat")).toBe("/chat");
expect(pathForTab("overview")).toBe("/overview");
});
it("prepends base path", () => {
expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
expect(pathForTab("sessions", "/apps/moltbot")).toBe("/apps/moltbot/sessions");
});
});
describe("tabFromPath", () => {
it("returns tab for valid path", () => {
expect(tabFromPath("/chat")).toBe("chat");
expect(tabFromPath("/overview")).toBe("overview");
expect(tabFromPath("/sessions")).toBe("sessions");
});
it("returns chat for root path", () => {
expect(tabFromPath("/")).toBe("chat");
});
it("handles base paths", () => {
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
expect(tabFromPath("/apps/moltbot/sessions", "/apps/moltbot")).toBe("sessions");
});
it("returns null for unknown path", () => {
expect(tabFromPath("/unknown")).toBeNull();
});
it("is case-insensitive", () => {
expect(tabFromPath("/CHAT")).toBe("chat");
expect(tabFromPath("/Overview")).toBe("overview");
});
});
describe("inferBasePathFromPathname", () => {
it("returns empty string for root", () => {
expect(inferBasePathFromPathname("/")).toBe("");
});
it("returns empty string for direct tab path", () => {
expect(inferBasePathFromPathname("/chat")).toBe("");
expect(inferBasePathFromPathname("/overview")).toBe("");
});
it("infers base path from nested paths", () => {
expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui");
expect(inferBasePathFromPathname("/apps/moltbot/sessions")).toBe("/apps/moltbot");
});
it("handles index.html suffix", () => {
expect(inferBasePathFromPathname("/index.html")).toBe("");
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
});
});
describe("TAB_GROUPS", () => {
it("contains all expected groups", () => {
const labels = TAB_GROUPS.map((g) => g.label);
expect(labels).toContain("Chat");
expect(labels).toContain("Control");
expect(labels).toContain("Agent");
expect(labels).toContain("Settings");
});
it("all tabs are unique", () => {
const allTabs = TAB_GROUPS.flatMap((g) => g.tabs);
const uniqueTabs = new Set(allTabs);
expect(uniqueTabs.size).toBe(allTabs.length);
});
});

View File

@@ -0,0 +1,188 @@
import type { IconName } from "./icons.js";
export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{
label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
] as const;
export type Tab =
| "overview"
| "channels"
| "instances"
| "sessions"
| "cron"
| "skills"
| "nodes"
| "chat"
| "config"
| "debug"
| "logs";
const TAB_PATHS: Record<Tab, string> = {
overview: "/overview",
channels: "/channels",
instances: "/instances",
sessions: "/sessions",
cron: "/cron",
skills: "/skills",
nodes: "/nodes",
chat: "/chat",
config: "/config",
debug: "/debug",
logs: "/logs",
};
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
);
export function normalizeBasePath(basePath: string): string {
if (!basePath) return "";
let base = basePath.trim();
if (!base.startsWith("/")) base = `/${base}`;
if (base === "/") return "";
if (base.endsWith("/")) base = base.slice(0, -1);
return base;
}
export function normalizePath(path: string): string {
if (!path) return "/";
let normalized = path.trim();
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
export function pathForTab(tab: Tab, basePath = ""): string {
const base = normalizeBasePath(basePath);
const path = TAB_PATHS[tab];
return base ? `${base}${path}` : path;
}
export function tabFromPath(pathname: string, basePath = ""): Tab | null {
const base = normalizeBasePath(basePath);
let path = pathname || "/";
if (base) {
if (path === base) {
path = "/";
} else if (path.startsWith(`${base}/`)) {
path = path.slice(base.length);
}
}
let normalized = normalizePath(path).toLowerCase();
if (normalized.endsWith("/index.html")) normalized = "/";
if (normalized === "/") return "chat";
return PATH_TO_TAB.get(normalized) ?? null;
}
export function inferBasePathFromPathname(pathname: string): string {
let normalized = normalizePath(pathname);
if (normalized.endsWith("/index.html")) {
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
}
if (normalized === "/") return "";
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0) return "";
for (let i = 0; i < segments.length; i++) {
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
if (PATH_TO_TAB.has(candidate)) {
const prefix = segments.slice(0, i);
return prefix.length ? `/${prefix.join("/")}` : "";
}
}
return `/${segments.join("/")}`;
}
export function iconForTab(tab: Tab): IconName {
switch (tab) {
case "chat":
return "messageSquare";
case "overview":
return "barChart";
case "channels":
return "link";
case "instances":
return "radio";
case "sessions":
return "fileText";
case "cron":
return "loader";
case "skills":
return "zap";
case "nodes":
return "monitor";
case "config":
return "settings";
case "debug":
return "bug";
case "logs":
return "scrollText";
default:
return "folder";
}
}
export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
case "channels":
return "Channels";
case "instances":
return "Instances";
case "sessions":
return "Sessions";
case "cron":
return "Cron Jobs";
case "skills":
return "Skills";
case "nodes":
return "Nodes";
case "chat":
return "Chat";
case "config":
return "Config";
case "debug":
return "Debug";
case "logs":
return "Logs";
default:
return "Control";
}
}
export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
case "channels":
return "Manage channels and settings.";
case "instances":
return "Presence beacons from connected clients and nodes.";
case "sessions":
return "Inspect active sessions and adjust per-session defaults.";
case "cron":
return "Schedule wakeups and recurring agent runs.";
case "skills":
return "Manage skill availability and API key injection.";
case "nodes":
return "Paired devices, capabilities, and command exposure.";
case "chat":
return "Direct gateway chat session for quick interventions.";
case "config":
return "Edit ~/.clawdbot/moltbot.json safely.";
case "debug":
return "Gateway snapshots, events, and manual RPC calls.";
case "logs":
return "Live tail of the gateway file logs.";
default:
return "";
}
}

View File

@@ -0,0 +1,57 @@
import { formatAgo, formatDurationMs, formatMs } from "./format";
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types";
export function formatPresenceSummary(entry: PresenceEntry): string {
const host = entry.host ?? "unknown";
const ip = entry.ip ? `(${entry.ip})` : "";
const mode = entry.mode ?? "";
const version = entry.version ?? "";
return `${host} ${ip} ${mode} ${version}`.trim();
}
export function formatPresenceAge(entry: PresenceEntry): string {
const ts = entry.ts ?? null;
return ts ? formatAgo(ts) : "n/a";
}
export function formatNextRun(ms?: number | null) {
if (!ms) return "n/a";
return `${formatMs(ms)} (${formatAgo(ms)})`;
}
export function formatSessionTokens(row: GatewaySessionRow) {
if (row.totalTokens == null) return "n/a";
const total = row.totalTokens ?? 0;
const ctx = row.contextTokens ?? 0;
return ctx ? `${total} / ${ctx}` : String(total);
}
export function formatEventPayload(payload: unknown): string {
if (payload == null) return "";
try {
return JSON.stringify(payload, null, 2);
} catch {
return String(payload);
}
}
export function formatCronState(job: CronJob) {
const state = job.state ?? {};
const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : "n/a";
const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : "n/a";
const status = state.lastStatus ?? "n/a";
return `${status} · next ${next} · last ${last}`;
}
export function formatCronSchedule(job: CronJob) {
const s = job.schedule;
if (s.kind === "at") return `At ${formatMs(s.atMs)}`;
if (s.kind === "every") return `Every ${formatDurationMs(s.everyMs)}`;
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
}
export function formatCronPayload(job: CronJob) {
const p = job.payload;
if (p.kind === "systemEvent") return `System: ${p.text}`;
return `Agent: ${p.message}`;
}

View File

@@ -0,0 +1,95 @@
const KEY = "moltbot.control.settings.v1";
import type { ThemeMode } from "./theme";
export type UiSettings = {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
};
export function loadSettings(): UiSettings {
const defaultUrl = (() => {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}`;
})();
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
};
try {
const raw = localStorage.getItem(KEY);
if (!raw) return defaults;
const parsed = JSON.parse(raw) as Partial<UiSettings>;
return {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
: defaults.sessionKey,
lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" &&
parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" &&
parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme:
parsed.theme === "light" ||
parsed.theme === "dark" ||
parsed.theme === "system"
? parsed.theme
: defaults.theme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
: defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
: defaults.chatShowThinking,
splitRatio:
typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 &&
parsed.splitRatio <= 0.7
? parsed.splitRatio
: defaults.splitRatio,
navCollapsed:
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed
: defaults.navCollapsed,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
};
} catch {
return defaults;
}
}
export function saveSettings(next: UiSettings) {
localStorage.setItem(KEY, JSON.stringify(next));
}

View File

@@ -0,0 +1,106 @@
import type { ThemeMode } from "./theme";
export type ThemeTransitionContext = {
element?: HTMLElement | null;
pointerClientX?: number;
pointerClientY?: number;
};
export type ThemeTransitionOptions = {
nextTheme: ThemeMode;
applyTheme: () => void;
context?: ThemeTransitionContext;
currentTheme?: ThemeMode | null;
};
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void) => { finished: Promise<void> };
};
const clamp01 = (value: number) => {
if (Number.isNaN(value)) return 0.5;
if (value <= 0) return 0;
if (value >= 1) return 1;
return value;
};
const hasReducedMotionPreference = () => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false;
};
const cleanupThemeTransition = (root: HTMLElement) => {
root.classList.remove("theme-transition");
root.style.removeProperty("--theme-switch-x");
root.style.removeProperty("--theme-switch-y");
};
export const startThemeTransition = ({
nextTheme,
applyTheme,
context,
currentTheme,
}: ThemeTransitionOptions) => {
if (currentTheme === nextTheme) return;
const documentReference = globalThis.document ?? null;
if (!documentReference) {
applyTheme();
return;
}
const root = documentReference.documentElement;
const document_ = documentReference as DocumentWithViewTransition;
const prefersReducedMotion = hasReducedMotionPreference();
const canUseViewTransition =
Boolean(document_.startViewTransition) && !prefersReducedMotion;
if (canUseViewTransition) {
let xPercent = 0.5;
let yPercent = 0.5;
if (
context?.pointerClientX !== undefined &&
context?.pointerClientY !== undefined &&
typeof window !== "undefined"
) {
xPercent = clamp01(context.pointerClientX / window.innerWidth);
yPercent = clamp01(context.pointerClientY / window.innerHeight);
} else if (context?.element) {
const rect = context.element.getBoundingClientRect();
if (
rect.width > 0 &&
rect.height > 0 &&
typeof window !== "undefined"
) {
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
}
}
root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
root.classList.add("theme-transition");
try {
const transition = document_.startViewTransition?.(() => {
applyTheme();
});
if (transition?.finished) {
void transition.finished.finally(() => cleanupThemeTransition(root));
} else {
cleanupThemeTransition(root);
}
} catch {
cleanupThemeTransition(root);
applyTheme();
}
return;
}
applyTheme();
cleanupThemeTransition(root);
};

View File

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

View File

@@ -0,0 +1,224 @@
{
"version": 1,
"fallback": {
"icon": "puzzle",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"id",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"icon": "wrench",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"icon": "wrench",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"icon": "fileText",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"icon": "edit",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"icon": "penLine",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"icon": "paperclip",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"icon": "globe",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"icon": "image",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"icon": "smartphone",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"icon": "loader",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["id"] },
"remove": { "label": "remove", "detailKeys": ["id"] },
"run": { "label": "run", "detailKeys": ["id"] },
"runs": { "label": "runs", "detailKeys": ["id"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"icon": "plug",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] },
"config.get": { "label": "config get" },
"config.schema": { "label": "config schema" },
"config.apply": {
"label": "config apply",
"detailKeys": ["restartDelayMs"]
},
"update.run": {
"label": "update run",
"detailKeys": ["restartDelayMs"]
}
}
},
"whatsapp_login": {
"icon": "circle",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"icon": "messageSquare",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
},
"slack": {
"icon": "messageSquare",
"title": "Slack",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
"emojiList": { "label": "emoji list" }
}
}
}
}

View File

@@ -0,0 +1,198 @@
import rawConfig from "./tool-display.json";
import type { IconName } from "./icons";
type ToolDisplayActionSpec = {
label?: string;
detailKeys?: string[];
};
type ToolDisplaySpec = {
icon?: string;
title?: string;
label?: string;
detailKeys?: string[];
actions?: Record<string, ToolDisplayActionSpec>;
};
type ToolDisplayConfig = {
version?: number;
fallback?: ToolDisplaySpec;
tools?: Record<string, ToolDisplaySpec>;
};
export type ToolDisplay = {
name: string;
icon: IconName;
title: string;
label: string;
verb?: string;
detail?: string;
};
const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { icon: "puzzle" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
function normalizeToolName(name?: string): string {
return (name ?? "tool").trim();
}
function defaultTitle(name: string): string {
const cleaned = name.replace(/_/g, " ").trim();
if (!cleaned) return "Tool";
return cleaned
.split(/\s+/)
.map((part) =>
part.length <= 2 && part.toUpperCase() === part
? part
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
)
.join(" ");
}
function normalizeVerb(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/_/g, " ");
}
function coerceDisplayValue(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
const values = value
.map((item) => coerceDisplayValue(item))
.filter((item): item is string => Boolean(item));
if (values.length === 0) return undefined;
const preview = values.slice(0, 3).join(", ");
return values.length > 3 ? `${preview}` : preview;
}
return undefined;
}
function lookupValueByPath(args: unknown, path: string): unknown {
if (!args || typeof args !== "object") return undefined;
let current: unknown = args;
for (const segment of path.split(".")) {
if (!segment) return undefined;
if (!current || typeof current !== "object") return undefined;
const record = current as Record<string, unknown>;
current = record[segment];
}
return current;
}
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
for (const key of keys) {
const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value);
if (display) return display;
}
return undefined;
}
function resolveReadDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
if (!path) return undefined;
const offset = typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${path}:${offset}-${offset + limit}`;
}
return path;
}
function resolveWriteDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
return path;
}
function resolveActionSpec(
spec: ToolDisplaySpec | undefined,
action: string | undefined,
): ToolDisplayActionSpec | undefined {
if (!spec || !action) return undefined;
return spec.actions?.[action] ?? undefined;
}
export function resolveToolDisplay(params: {
name?: string;
args?: unknown;
meta?: string;
}): ToolDisplay {
const name = normalizeToolName(params.name);
const key = name.toLowerCase();
const spec = TOOL_MAP[key];
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName;
const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? name;
const actionRaw =
params.args && typeof params.args === "object"
? ((params.args as Record<string, unknown>).action as string | undefined)
: undefined;
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
const actionSpec = resolveActionSpec(spec, action);
const verb = normalizeVerb(actionSpec?.label ?? action);
let detail: string | undefined;
if (key === "read") detail = resolveReadDetail(params.args);
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys);
}
if (!detail && params.meta) {
detail = params.meta;
}
if (detail) {
detail = shortenHomeInString(detail);
}
return {
name,
icon,
title,
label,
verb,
detail,
};
}
export function formatToolDetail(display: ToolDisplay): string | undefined {
const parts: string[] = [];
if (display.verb) parts.push(display.verb);
if (display.detail) parts.push(display.detail);
if (parts.length === 0) return undefined;
return parts.join(" · ");
}
export function formatToolSummary(display: ToolDisplay): string {
const detail = formatToolDetail(display);
return detail ? `${display.label}: ${detail}` : display.label;
}
function shortenHomeInString(input: string): string {
if (!input) return input;
return input
.replace(/\/Users\/[^/]+/g, "~")
.replace(/\/home\/[^/]+/g, "~");
}

View File

@@ -0,0 +1,532 @@
export type ChannelsStatusSnapshot = {
ts: number;
channelOrder: string[];
channelLabels: Record<string, string>;
channelDetailLabels?: Record<string, string>;
channelSystemImages?: Record<string, string>;
channelMeta?: ChannelUiMetaEntry[];
channels: Record<string, unknown>;
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
channelDefaultAccountId: Record<string, string>;
};
export type ChannelUiMetaEntry = {
id: string;
label: string;
detailLabel: string;
systemImage?: string;
};
export const CRON_CHANNEL_LAST = "last";
export type ChannelAccountSnapshot = {
accountId: string;
name?: string | null;
enabled?: boolean | null;
configured?: boolean | null;
linked?: boolean | null;
running?: boolean | null;
connected?: boolean | null;
reconnectAttempts?: number | null;
lastConnectedAt?: number | null;
lastError?: string | null;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
lastProbeAt?: number | null;
mode?: string | null;
dmPolicy?: string | null;
allowFrom?: string[] | null;
tokenSource?: string | null;
botTokenSource?: string | null;
appTokenSource?: string | null;
credentialSource?: string | null;
audienceType?: string | null;
audience?: string | null;
webhookPath?: string | null;
webhookUrl?: string | null;
baseUrl?: string | null;
allowUnmentionedGroups?: boolean | null;
cliPath?: string | null;
dbPath?: string | null;
port?: number | null;
probe?: unknown;
audit?: unknown;
application?: unknown;
};
export type WhatsAppSelf = {
e164?: string | null;
jid?: string | null;
};
export type WhatsAppDisconnect = {
at: number;
status?: number | null;
error?: string | null;
loggedOut?: boolean | null;
};
export type WhatsAppStatus = {
configured: boolean;
linked: boolean;
authAgeMs?: number | null;
self?: WhatsAppSelf | null;
running: boolean;
connected: boolean;
lastConnectedAt?: number | null;
lastDisconnect?: WhatsAppDisconnect | null;
reconnectAttempts: number;
lastMessageAt?: number | null;
lastEventAt?: number | null;
lastError?: string | null;
};
export type TelegramBot = {
id?: number | null;
username?: string | null;
};
export type TelegramWebhook = {
url?: string | null;
hasCustomCert?: boolean | null;
};
export type TelegramProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: TelegramBot | null;
webhook?: TelegramWebhook | null;
};
export type TelegramStatus = {
configured: boolean;
tokenSource?: string | null;
running: boolean;
mode?: string | null;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: TelegramProbe | null;
lastProbeAt?: number | null;
};
export type DiscordBot = {
id?: string | null;
username?: string | null;
};
export type DiscordProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: DiscordBot | null;
};
export type DiscordStatus = {
configured: boolean;
tokenSource?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: DiscordProbe | null;
lastProbeAt?: number | null;
};
export type GoogleChatProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
};
export type GoogleChatStatus = {
configured: boolean;
credentialSource?: string | null;
audienceType?: string | null;
audience?: string | null;
webhookPath?: string | null;
webhookUrl?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: GoogleChatProbe | null;
lastProbeAt?: number | null;
};
export type SlackBot = {
id?: string | null;
name?: string | null;
};
export type SlackTeam = {
id?: string | null;
name?: string | null;
};
export type SlackProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: SlackBot | null;
team?: SlackTeam | null;
};
export type SlackStatus = {
configured: boolean;
botTokenSource?: string | null;
appTokenSource?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: SlackProbe | null;
lastProbeAt?: number | null;
};
export type SignalProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
version?: string | null;
};
export type SignalStatus = {
configured: boolean;
baseUrl: string;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: SignalProbe | null;
lastProbeAt?: number | null;
};
export type IMessageProbe = {
ok: boolean;
error?: string | null;
};
export type IMessageStatus = {
configured: boolean;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
cliPath?: string | null;
dbPath?: string | null;
probe?: IMessageProbe | null;
lastProbeAt?: number | null;
};
export type NostrProfile = {
name?: string | null;
displayName?: string | null;
about?: string | null;
picture?: string | null;
banner?: string | null;
website?: string | null;
nip05?: string | null;
lud16?: string | null;
};
export type NostrStatus = {
configured: boolean;
publicKey?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
profile?: NostrProfile | null;
};
export type MSTeamsProbe = {
ok: boolean;
error?: string | null;
appId?: string | null;
};
export type MSTeamsStatus = {
configured: boolean;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
port?: number | null;
probe?: MSTeamsProbe | null;
lastProbeAt?: number | null;
};
export type ConfigSnapshotIssue = {
path: string;
message: string;
};
export type ConfigSnapshot = {
path?: string | null;
exists?: boolean | null;
raw?: string | null;
hash?: string | null;
parsed?: unknown;
valid?: boolean | null;
config?: Record<string, unknown> | null;
issues?: ConfigSnapshotIssue[] | null;
};
export type ConfigUiHint = {
label?: string;
help?: string;
group?: string;
order?: number;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ConfigUiHints = Record<string, ConfigUiHint>;
export type ConfigSchemaResponse = {
schema: unknown;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
export type PresenceEntry = {
instanceId?: string | null;
host?: string | null;
ip?: string | null;
version?: string | null;
platform?: string | null;
deviceFamily?: string | null;
modelIdentifier?: string | null;
mode?: string | null;
lastInputSeconds?: number | null;
reason?: string | null;
text?: string | null;
ts?: number | null;
};
export type GatewaySessionsDefaults = {
model: string | null;
contextTokens: number | null;
};
export type GatewayAgentRow = {
id: string;
name?: string;
identity?: {
name?: string;
theme?: string;
emoji?: string;
avatar?: string;
avatarUrl?: string;
};
};
export type AgentsListResult = {
defaultId: string;
mainKey: string;
scope: string;
agents: GatewayAgentRow[];
};
export type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
surface?: string;
subject?: string;
room?: string;
space?: string;
updatedAt: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
model?: string;
modelProvider?: string;
contextTokens?: number;
};
export type SessionsListResult = {
ts: number;
path: string;
count: number;
defaults: GatewaySessionsDefaults;
sessions: GatewaySessionRow[];
};
export type SessionsPatchResult = {
ok: true;
path: string;
key: string;
entry: {
sessionId: string;
updatedAt?: number;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
};
};
export type CronSchedule =
| { kind: "at"; atMs: number }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
kind: "agentTurn";
message: string;
thinking?: string;
timeoutSeconds?: number;
deliver?: boolean;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
bestEffortDeliver?: boolean;
};
export type CronIsolation = {
postToMainPrefix?: string;
};
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: "ok" | "error" | "skipped";
lastError?: string;
lastDurationMs?: number;
};
export type CronJob = {
id: string;
agentId?: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
isolation?: CronIsolation;
state?: CronJobState;
};
export type CronStatus = {
enabled: boolean;
jobs: number;
nextWakeAtMs?: number | null;
};
export type CronRunLogEntry = {
ts: number;
jobId: string;
status: "ok" | "error" | "skipped";
durationMs?: number;
error?: string;
summary?: string;
};
export type SkillsStatusConfigCheck = {
path: string;
value: unknown;
satisfied: boolean;
};
export type SkillInstallOption = {
id: string;
kind: "brew" | "node" | "go" | "uv";
label: string;
bins: string[];
};
export type SkillStatusEntry = {
name: string;
description: string;
source: string;
filePath: string;
baseDir: string;
skillKey: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
always: boolean;
disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean;
requirements: {
bins: string[];
env: string[];
config: string[];
os: string[];
};
missing: {
bins: string[];
env: string[];
config: string[];
os: string[];
};
configChecks: SkillsStatusConfigCheck[];
install: SkillInstallOption[];
};
export type SkillStatusReport = {
workspaceDir: string;
managedSkillsDir: string;
skills: SkillStatusEntry[];
};
export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
export type LogLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type LogEntry = {
raw: string;
time?: string | null;
level?: LogLevel | null;
subsystem?: string | null;
message?: string | null;
meta?: Record<string, unknown> | null;
};

View File

@@ -0,0 +1,43 @@
/**
* Chat message types for the UI layer.
*/
/** Union type for items in the chat thread */
export type ChatItem =
| { kind: "message"; key: string; message: unknown }
| { kind: "stream"; key: string; text: string; startedAt: number }
| { kind: "reading-indicator"; key: string };
/** A group of consecutive messages from the same role (Slack-style layout) */
export type MessageGroup = {
kind: "group";
key: string;
role: string;
messages: Array<{ message: unknown; key: string }>;
timestamp: number;
isStreaming: boolean;
};
/** Content item types in a normalized message */
export type MessageContentItem = {
type: "text" | "tool_call" | "tool_result";
text?: string;
name?: string;
args?: unknown;
};
/** Normalized message structure for rendering */
export type NormalizedMessage = {
role: string;
content: MessageContentItem[];
timestamp: number;
id?: string;
};
/** Tool card representation for tool calls and results */
export type ToolCard = {
kind: "call" | "result";
name: string;
args?: unknown;
text?: string;
};

View File

@@ -0,0 +1,36 @@
export type ChatAttachment = {
id: string;
dataUrl: string;
mimeType: string;
};
export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
attachments?: ChatAttachment[];
};
export const CRON_CHANNEL_LAST = "last";
export type CronFormState = {
name: string;
description: string;
agentId: string;
enabled: boolean;
scheduleKind: "at" | "every" | "cron";
scheduleAt: string;
everyAmount: string;
everyUnit: "minutes" | "hours" | "days";
cronExpr: string;
cronTz: string;
sessionTarget: "main" | "isolated";
wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn";
payloadText: string;
deliver: boolean;
channel: string;
to: string;
timeoutSeconds: string;
postToMainPrefix: string;
};

View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { generateUUID } from "./uuid";
describe("generateUUID", () => {
it("uses crypto.randomUUID when available", () => {
const id = generateUUID({
randomUUID: () => "randomuuid",
getRandomValues: () => {
throw new Error("should not be called");
},
});
expect(id).toBe("randomuuid");
});
it("falls back to crypto.getRandomValues", () => {
const id = generateUUID({
getRandomValues: (bytes) => {
for (let i = 0; i < bytes.length; i++) bytes[i] = i;
return bytes;
},
});
expect(id).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
});
it("still returns a v4 UUID when crypto is missing", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const id = generateUUID(null);
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(warnSpy).toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,51 @@
export type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
};
let warnedWeakCrypto = false;
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20,
)}-${hex.slice(20)}`;
}
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
const now = Date.now();
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
bytes[3] ^= (now >>> 24) & 0xff;
return bytes;
}
function warnWeakCryptoOnce() {
if (warnedWeakCrypto) return;
warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness");
}
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoLike.getRandomValues(bytes);
return uuidFromBytes(bytes);
}
warnWeakCryptoOnce();
return uuidFromBytes(weakRandomBytes());
}

View File

@@ -0,0 +1,134 @@
import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
analyzeConfigSchema,
renderNode,
schemaType,
type JsonSchema,
} from "./config-form";
type ChannelConfigFormProps = {
channelId: string;
configValue: Record<string, unknown> | null;
schema: unknown | null;
uiHints: ConfigUiHints;
disabled: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
function resolveSchemaNode(
schema: JsonSchema | null,
path: Array<string | number>,
): JsonSchema | null {
let current = schema;
for (const key of path) {
if (!current) return null;
const type = schemaType(current);
if (type === "object") {
const properties = current.properties ?? {};
if (typeof key === "string" && properties[key]) {
current = properties[key];
continue;
}
const additional = current.additionalProperties;
if (typeof key === "string" && additional && typeof additional === "object") {
current = additional as JsonSchema;
continue;
}
return null;
}
if (type === "array") {
if (typeof key !== "number") return null;
const items = Array.isArray(current.items) ? current.items[0] : current.items;
current = items ?? null;
continue;
}
return null;
}
return current;
}
function resolveChannelValue(
config: Record<string, unknown>,
channelId: string,
): Record<string, unknown> {
const channels = (config.channels ?? {}) as Record<string, unknown>;
const fromChannels = channels[channelId];
const fallback = config[channelId];
const resolved =
(fromChannels && typeof fromChannels === "object"
? (fromChannels as Record<string, unknown>)
: null) ??
(fallback && typeof fallback === "object"
? (fallback as Record<string, unknown>)
: null);
return resolved ?? {};
}
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
return html`
<div class="config-form">
${renderNode({
schema: node,
value,
path: ["channels", props.channelId],
hints: props.uiHints,
unsupported: new Set(analysis.unsupportedPaths),
disabled: props.disabled,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
`;
}
export function renderChannelConfigSection(params: {
channelId: string;
props: ChannelsProps;
}) {
const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading;
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderDiscordCard(params: {
props: ChannelsProps;
discord?: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,74 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { GoogleChatStatus } from "../types";
import { renderChannelConfigSection } from "./channels.config";
import type { ChannelsProps } from "./channels.types";
export function renderGoogleChatCard(params: {
props: ChannelsProps;
googleChat?: GoogleChatStatus | null;
accountCountLabel: unknown;
}) {
const { props, googleChat, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
</div>
<div>
<span class="label">Audience</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
</div>
</div>
${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing}
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "googlechat", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderIMessageCard(params: {
props: ChannelsProps;
imessage?: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,312 @@
/**
* Nostr Profile Edit Form
*
* Provides UI for editing and publishing Nostr profile (kind:0).
*/
import { html, nothing, type TemplateResult } from "lit";
import type { NostrProfile as NostrProfileType } from "../types";
// ============================================================================
// Types
// ============================================================================
export interface NostrProfileFormState {
/** Current form values */
values: NostrProfileType;
/** Original values for dirty detection */
original: NostrProfileType;
/** Whether the form is currently submitting */
saving: boolean;
/** Whether import is in progress */
importing: boolean;
/** Last error message */
error: string | null;
/** Last success message */
success: string | null;
/** Validation errors per field */
fieldErrors: Record<string, string>;
/** Whether to show advanced fields */
showAdvanced: boolean;
}
export interface NostrProfileFormCallbacks {
/** Called when a field value changes */
onFieldChange: (field: keyof NostrProfileType, value: string) => void;
/** Called when save is clicked */
onSave: () => void;
/** Called when import is clicked */
onImport: () => void;
/** Called when cancel is clicked */
onCancel: () => void;
/** Called when toggle advanced is clicked */
onToggleAdvanced: () => void;
}
// ============================================================================
// Helpers
// ============================================================================
function isFormDirty(state: NostrProfileFormState): boolean {
const { values, original } = state;
return (
values.name !== original.name ||
values.displayName !== original.displayName ||
values.about !== original.about ||
values.picture !== original.picture ||
values.banner !== original.banner ||
values.website !== original.website ||
values.nip05 !== original.nip05 ||
values.lud16 !== original.lud16
);
}
// ============================================================================
// Form Rendering
// ============================================================================
export function renderNostrProfileForm(params: {
state: NostrProfileFormState;
callbacks: NostrProfileFormCallbacks;
accountId: string;
}): TemplateResult {
const { state, callbacks, accountId } = params;
const isDirty = isFormDirty(state);
const renderField = (
field: keyof NostrProfileType,
label: string,
opts: {
type?: "text" | "url" | "textarea";
placeholder?: string;
maxLength?: number;
help?: string;
} = {}
) => {
const { type = "text", placeholder, maxLength, help } = opts;
const value = state.values[field] ?? "";
const error = state.fieldErrors[field];
const inputId = `nostr-profile-${field}`;
if (type === "textarea") {
return html`
<div class="form-field" style="margin-bottom: 12px;">
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
${label}
</label>
<textarea
id="${inputId}"
.value=${value}
placeholder=${placeholder ?? ""}
maxlength=${maxLength ?? 2000}
rows="3"
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;"
@input=${(e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
callbacks.onFieldChange(field, target.value);
}}
?disabled=${state.saving}
></textarea>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
</div>
`;
}
return html`
<div class="form-field" style="margin-bottom: 12px;">
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
${label}
</label>
<input
id="${inputId}"
type=${type}
.value=${value}
placeholder=${placeholder ?? ""}
maxlength=${maxLength ?? 256}
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;"
@input=${(e: InputEvent) => {
const target = e.target as HTMLInputElement;
callbacks.onFieldChange(field, target.value);
}}
?disabled=${state.saving}
/>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
</div>
`;
};
const renderPicturePreview = () => {
const picture = state.values.picture;
if (!picture) return nothing;
return html`
<div style="margin-bottom: 12px;">
<img
src=${picture}
alt="Profile picture preview"
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = "none";
}}
@load=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = "block";
}}
/>
</div>
`;
};
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
</div>
${state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing}
${state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing}
${renderPicturePreview()}
${renderField("name", "Username", {
placeholder: "satoshi",
maxLength: 256,
help: "Short username (e.g., satoshi)",
})}
${renderField("displayName", "Display Name", {
placeholder: "Satoshi Nakamoto",
maxLength: 256,
help: "Your full display name",
})}
${renderField("about", "Bio", {
type: "textarea",
placeholder: "Tell people about yourself...",
maxLength: 2000,
help: "A brief bio or description",
})}
${renderField("picture", "Avatar URL", {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
})}
${state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
${renderField("banner", "Banner URL", {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
})}
${renderField("website", "Website", {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
})}
${renderField("nip05", "NIP-05 Identifier", {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
})}
${renderField("lud16", "Lightning Address", {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
})}
</div>
`
: nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
class="btn primary"
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save & Publish"}
</button>
<button
class="btn"
@click=${callbacks.onImport}
?disabled=${state.importing || state.saving}
>
${state.importing ? "Importing..." : "Import from Relays"}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button>
<button
class="btn"
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
</button>
</div>
${isDirty
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
You have unsaved changes
</div>`
: nothing}
</div>
`;
}
// ============================================================================
// Factory
// ============================================================================
/**
* Create initial form state from existing profile
*/
export function createNostrProfileFormState(
profile: NostrProfileType | undefined
): NostrProfileFormState {
const values: NostrProfileType = {
name: profile?.name ?? "",
displayName: profile?.displayName ?? "",
about: profile?.about ?? "",
picture: profile?.picture ?? "",
banner: profile?.banner ?? "",
website: profile?.website ?? "",
nip05: profile?.nip05 ?? "",
lud16: profile?.lud16 ?? "",
};
return {
values,
original: { ...values },
saving: false,
importing: false,
error: null,
success: null,
fieldErrors: {},
showAdvanced: Boolean(
profile?.banner || profile?.website || profile?.nip05 || profile?.lud16
),
};
}

View File

@@ -0,0 +1,217 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import {
renderNostrProfileForm,
type NostrProfileFormState,
type NostrProfileFormCallbacks,
} from "./channels.nostr-profile-form";
/**
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) return "n/a";
if (pubkey.length <= 20) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
export function renderNostrCard(params: {
props: ChannelsProps;
nostr?: NostrStatus | null;
nostrAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
/** Profile form state (optional - if provided, shows form) */
profileFormState?: NostrProfileFormState | null;
/** Profile form callbacks */
profileFormCallbacks?: NostrProfileFormCallbacks | null;
/** Called when Edit Profile is clicked */
onEditProfile?: () => void;
}) {
const {
props,
nostr,
nostrAccounts,
accountCountLabel,
profileFormState,
profileFormCallbacks,
onEditProfile,
} = params;
const primaryAccount = nostrAccounts[0];
const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
const summaryPublicKey =
nostr?.publicKey ??
(primaryAccount as { publicKey?: string } | undefined)?.publicKey;
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
const showingForm = profileFormState !== null && profileFormState !== undefined;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const publicKey = (account as { publicKey?: string }).publicKey;
const profile = (account as { profile?: { name?: string; displayName?: string } }).profile;
const displayName = profile?.displayName ?? profile?.name ?? account.name ?? account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">${displayName}</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing}
</div>
</div>
`;
};
const renderProfileSection = () => {
// If showing form, render the form instead of the read-only view
if (showingForm && profileFormCallbacks) {
return renderNostrProfileForm({
state: profileFormState,
callbacks: profileFormCallbacks,
accountId: nostrAccounts[0]?.accountId ?? "default",
});
}
const profile =
(primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
${summaryConfigured
? html`
<button
class="btn btn-sm"
@click=${onEditProfile}
style="font-size: 12px; padding: 4px 8px;"
>
Edit Profile
</button>
`
: nothing}
</div>
${hasAnyProfileData
? html`
<div class="status-list">
${picture
? html`
<div style="margin-bottom: 8px;">
<img
src=${picture}
alt="Profile picture"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</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>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`}
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${summaryConfigured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${summaryRunning ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Public Key</span>
<span class="monospace" title="${summaryPublicKey ?? ""}"
>${truncatePubkey(summaryPublicKey)}</span
>
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</div>
</div>
`}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,45 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) return false;
const channelStatus = channels[key] as Record<string, unknown> | undefined;
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected;
const accounts = snapshot.channelAccounts?.[key] ?? [];
const accountActive = accounts.some(
(account) => account.configured || account.running || account.connected,
);
return configured || running || connected || accountActive;
}
export function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
export function renderChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@@ -0,0 +1,66 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSignalCard(params: {
props: ChannelsProps;
signal?: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSlackCard(params: {
props: ChannelsProps;
slack?: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

Some files were not shown because too many files have changed in this diff Show More