Merge branches 'dashboard-v2-chat-infra' and 'dashboard-v2-ui-utils' into dashboard-v2-views-refactor

This commit is contained in:
Val Alexander
2026-03-09 17:55:46 -05:00
14 changed files with 817 additions and 223 deletions

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const en: TranslationMap = {
common: {
version: "Version",
health: "Health",
ok: "OK",
offline: "Offline",
@@ -12,7 +11,9 @@ export const en: TranslationMap = {
disabled: "Disabled",
na: "n/a",
docs: "Docs",
theme: "Theme",
resources: "Resources",
search: "Search",
},
nav: {
chat: "Chat",
@@ -21,6 +22,7 @@ export const en: TranslationMap = {
settings: "Settings",
expand: "Expand sidebar",
collapse: "Collapse sidebar",
resize: "Resize sidebar",
},
tabs: {
agents: "Agents",
@@ -34,23 +36,33 @@ export const en: TranslationMap = {
nodes: "Nodes",
chat: "Chat",
config: "Config",
communications: "Communications",
appearance: "Appearance",
automation: "Automation",
infrastructure: "Infrastructure",
aiAgents: "AI & Agents",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Manage agent workspaces, tools, and identities.",
overview: "Gateway status, entry points, and a fast health read.",
channels: "Manage channels and settings.",
instances: "Presence beacons from connected clients and nodes.",
sessions: "Inspect active sessions and adjust per-session defaults.",
usage: "Monitor API usage and costs.",
cron: "Schedule wakeups and recurring agent runs.",
skills: "Manage skill availability and API key injection.",
nodes: "Paired devices, capabilities, and command exposure.",
chat: "Direct gateway chat session for quick interventions.",
config: "Edit ~/.openclaw/openclaw.json safely.",
debug: "Gateway snapshots, events, and manual RPC calls.",
logs: "Live tail of the gateway file logs.",
agents: "Workspaces, tools, identities.",
overview: "Status, entry points, health.",
channels: "Channels and settings.",
instances: "Connected clients and nodes.",
sessions: "Active sessions and defaults.",
usage: "API usage and costs.",
cron: "Wakeups and recurring runs.",
skills: "Skills and API keys.",
nodes: "Paired devices and commands.",
chat: "Gateway chat for quick interventions.",
config: "Edit openclaw.json.",
communications: "Channels, messages, and audio settings.",
appearance: "Theme, UI, and setup wizard settings.",
automation: "Commands, hooks, cron, and plugins.",
infrastructure: "Gateway, web, browser, and media settings.",
aiAgents: "Agents, models, skills, tools, memory, session.",
debug: "Snapshots, events, RPC.",
logs: "Live gateway logs.",
},
overview: {
access: {
@@ -105,6 +117,47 @@ export const en: TranslationMap = {
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
},
connection: {
title: "How to connect",
step1: "Start the gateway on your host machine:",
step2: "Get a tokenized dashboard URL:",
step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.",
step4: "Or generate a reusable token:",
docsHint: "For remote access, Tailscale Serve is recommended. ",
docsLink: "Read the docs →",
},
cards: {
cost: "Cost",
skills: "Skills",
recentSessions: "Recent Sessions",
},
attention: {
title: "Attention",
},
eventLog: {
title: "Event Log",
},
logTail: {
title: "Gateway Logs",
},
quickActions: {
newSession: "New Session",
automation: "Automation",
refreshAll: "Refresh All",
terminal: "Terminal",
},
streamMode: {
active: "Stream mode — values redacted",
disable: "Disable",
},
palette: {
placeholder: "Type a command…",
noResults: "No results",
},
},
login: {
subtitle: "Gateway Dashboard",
passwordPlaceholder: "optional",
},
chat: {
disconnected: "Disconnected from gateway.",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const pt_BR: TranslationMap = {
common: {
version: "Versão",
health: "Saúde",
ok: "OK",
offline: "Offline",
@@ -13,6 +12,7 @@ export const pt_BR: TranslationMap = {
na: "n/a",
docs: "Docs",
resources: "Recursos",
search: "Pesquisar",
},
nav: {
chat: "Chat",
@@ -21,6 +21,7 @@ export const pt_BR: TranslationMap = {
settings: "Configurações",
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral",
resize: "Redimensionar barra lateral",
},
tabs: {
agents: "Agentes",
@@ -34,23 +35,33 @@ export const pt_BR: TranslationMap = {
nodes: "Nós",
chat: "Chat",
config: "Config",
communications: "Comunicações",
appearance: "Aparência e Configuração",
automation: "Automação",
infrastructure: "Infraestrutura",
aiAgents: "IA e Agentes",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
channels: "Gerenciar canais e configurações.",
instances: "Beacons de presença de clientes e nós conectados.",
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
usage: "Monitorar uso e custos da API.",
cron: "Agendar despertares e execuções recorrentes de agentes.",
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
config: "Editar ~/.openclaw/openclaw.json com segurança.",
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
agents: "Espaços, ferramentas, identidades.",
overview: "Status, entrada, saúde.",
channels: "Canais e configurações.",
instances: "Clientes e nós conectados.",
sessions: "Sessões ativas e padrões.",
usage: "Uso e custos da API.",
cron: "Despertares e execuções.",
skills: "Habilidades e chaves API.",
nodes: "Dispositivos e comandos.",
chat: "Chat do gateway para intervenções rápidas.",
config: "Editar openclaw.json.",
communications: "Configurações de canais, mensagens e áudio.",
appearance: "Configurações de tema, UI e assistente de configuração.",
automation: "Configurações de comandos, hooks, cron e plugins.",
infrastructure: "Configurações de gateway, web, browser e mídia.",
aiAgents: "Configurações de agentes, modelos, habilidades, ferramentas, memória e sessão.",
debug: "Snapshots, eventos, RPC.",
logs: "Logs ao vivo do gateway.",
},
overview: {
access: {
@@ -107,6 +118,47 @@ export const pt_BR: TranslationMap = {
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
},
connection: {
title: "Como conectar",
step1: "Inicie o gateway na sua máquina host:",
step2: "Obtenha uma URL do painel com token:",
step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.",
step4: "Ou gere um token reutilizável:",
docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ",
docsLink: "Leia a documentação →",
},
cards: {
cost: "Custo",
skills: "Habilidades",
recentSessions: "Sessões Recentes",
},
attention: {
title: "Atenção",
},
eventLog: {
title: "Log de Eventos",
},
logTail: {
title: "Logs do Gateway",
},
quickActions: {
newSession: "Nova Sessão",
automation: "Automação",
refreshAll: "Atualizar Tudo",
terminal: "Terminal",
},
streamMode: {
active: "Modo stream — valores ocultos",
disable: "Desativar",
},
palette: {
placeholder: "Digite um comando…",
noResults: "Sem resultados",
},
},
login: {
subtitle: "Painel do Gateway",
passwordPlaceholder: "opcional",
},
chat: {
disconnected: "Desconectado do gateway.",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_CN: TranslationMap = {
common: {
version: "版本",
health: "健康状况",
ok: "正常",
offline: "离线",
@@ -13,6 +12,7 @@ export const zh_CN: TranslationMap = {
na: "不适用",
docs: "文档",
resources: "资源",
search: "搜索",
},
nav: {
chat: "聊天",
@@ -21,6 +21,7 @@ export const zh_CN: TranslationMap = {
settings: "设置",
expand: "展开侧边栏",
collapse: "折叠侧边栏",
resize: "调整侧边栏大小",
},
tabs: {
agents: "代理",
@@ -34,23 +35,33 @@ export const zh_CN: TranslationMap = {
nodes: "节点",
chat: "聊天",
config: "配置",
communications: "通信",
appearance: "外观与设置",
automation: "自动化",
infrastructure: "基础设施",
aiAgents: "AI 与代理",
debug: "调试",
logs: "日志",
},
subtitles: {
agents: "管理代理工作区、工具身份。",
overview: "网关状态、入口点和快速健康读取。",
channels: "管理频道和设置。",
instances: "来自已连接客户端和节点的在线信号。",
sessions: "检查活动会话并调整每个会话的默认设置。",
usage: "监控 API 使用情况和成本。",
cron: "安排唤醒和重复的代理运行。",
skills: "管理技能可用性和 API 密钥注入。",
nodes: "配对设备、功能和命令公开。",
chat: "用于快速干预的直接网关聊天会话。",
config: "安全地编辑 ~/.openclaw/openclaw.json。",
debug: "网关快照、事件和手动 RPC 调用。",
logs: "网关文件日志的实时追踪。",
agents: "工作区、工具身份。",
overview: "状态、入口点、健康。",
channels: "频道和设置。",
instances: "已连接客户端和节点。",
sessions: "活动会话默认设置。",
usage: "API 使用情况和成本。",
cron: "唤醒和重复运行。",
skills: "技能和 API 密钥。",
nodes: "配对设备和命令。",
chat: "网关聊天,快速干预。",
config: "编辑 openclaw.json。",
communications: "频道、消息和音频设置。",
appearance: "主题、界面和设置向导设置。",
automation: "命令、钩子、定时任务和插件设置。",
infrastructure: "网关、Web、浏览器和媒体设置。",
aiAgents: "代理、模型、技能、工具、记忆和会话设置。",
debug: "快照、事件、RPC。",
logs: "实时网关日志。",
},
overview: {
access: {
@@ -104,6 +115,47 @@ export const zh_CN: TranslationMap = {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。",
},
connection: {
title: "如何连接",
step1: "在主机上启动网关:",
step2: "获取带令牌的仪表盘 URL",
step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。",
step4: "或生成可重复使用的令牌:",
docsHint: "如需远程访问,建议使用 Tailscale Serve。",
docsLink: "查看文档 →",
},
cards: {
cost: "费用",
skills: "技能",
recentSessions: "最近会话",
},
attention: {
title: "注意事项",
},
eventLog: {
title: "事件日志",
},
logTail: {
title: "网关日志",
},
quickActions: {
newSession: "新建会话",
automation: "自动化",
refreshAll: "全部刷新",
terminal: "终端",
},
streamMode: {
active: "流模式 — 数据已隐藏",
disable: "禁用",
},
palette: {
placeholder: "输入命令…",
noResults: "无结果",
},
},
login: {
subtitle: "网关仪表盘",
passwordPlaceholder: "可选",
},
chat: {
disconnected: "已断开与网关的连接。",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_TW: TranslationMap = {
common: {
version: "版本",
health: "健康狀況",
ok: "正常",
offline: "離線",
@@ -13,6 +12,7 @@ export const zh_TW: TranslationMap = {
na: "不適用",
docs: "文檔",
resources: "資源",
search: "搜尋",
},
nav: {
chat: "聊天",
@@ -21,6 +21,7 @@ export const zh_TW: TranslationMap = {
settings: "設置",
expand: "展開側邊欄",
collapse: "折疊側邊欄",
resize: "調整側邊欄大小",
},
tabs: {
agents: "代理",
@@ -34,23 +35,33 @@ export const zh_TW: TranslationMap = {
nodes: "節點",
chat: "聊天",
config: "配置",
communications: "通訊",
appearance: "外觀與設置",
automation: "自動化",
infrastructure: "基礎設施",
aiAgents: "AI 與代理",
debug: "調試",
logs: "日誌",
},
subtitles: {
agents: "管理代理工作區、工具身份。",
overview: "網關狀態、入口點和快速健康讀取。",
channels: "管理頻道和設置。",
instances: "來自已連接客戶端和節點的在線信號。",
sessions: "檢查活動會話並調整每個會話的默認設置。",
usage: "監控 API 使用情況和成本。",
cron: "安排喚醒和重複的代理運行。",
skills: "管理技能可用性和 API 密鑰注入。",
nodes: "配對設備、功能和命令公開。",
chat: "用於快速干預的直接網關聊天會話。",
config: "安全地編輯 ~/.openclaw/openclaw.json。",
debug: "網關快照、事件和手動 RPC 調用。",
logs: "網關文件日志的實時追蹤。",
agents: "工作區、工具身份。",
overview: "狀態、入口點、健康。",
channels: "頻道和設置。",
instances: "已連接客戶端和節點。",
sessions: "活動會話默認設置。",
usage: "API 使用情況和成本。",
cron: "喚醒和重複運行。",
skills: "技能和 API 密鑰。",
nodes: "配對設備和命令。",
chat: "網關聊天,快速干預。",
config: "編輯 openclaw.json。",
communications: "頻道、消息和音頻設置。",
appearance: "主題、界面和設置向導設置。",
automation: "命令、鉤子、定時任務和插件設置。",
infrastructure: "網關、Web、瀏覽器和媒體設置。",
aiAgents: "代理、模型、技能、工具、記憶和會話設置。",
debug: "快照、事件、RPC。",
logs: "實時網關日誌。",
},
overview: {
access: {
@@ -104,6 +115,47 @@ export const zh_TW: TranslationMap = {
hint: "此頁面為 HTTP因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
stayHttp: "如果您必須保持 HTTP請設置 {config} (僅限令牌)。",
},
connection: {
title: "如何連接",
step1: "在主機上啟動閘道:",
step2: "取得帶令牌的儀表板 URL",
step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。",
step4: "或產生可重複使用的令牌:",
docsHint: "如需遠端存取,建議使用 Tailscale Serve。",
docsLink: "查看文件 →",
},
cards: {
cost: "費用",
skills: "技能",
recentSessions: "最近會話",
},
attention: {
title: "注意事項",
},
eventLog: {
title: "事件日誌",
},
logTail: {
title: "閘道日誌",
},
quickActions: {
newSession: "新建會話",
automation: "自動化",
refreshAll: "全部刷新",
terminal: "終端",
},
streamMode: {
active: "串流模式 — 數據已隱藏",
disable: "禁用",
},
palette: {
placeholder: "輸入指令…",
noResults: "無結果",
},
},
login: {
subtitle: "閘道儀表板",
passwordPlaceholder: "可選",
},
chat: {
disconnected: "已斷開與網關的連接。",

View File

@@ -9,17 +9,19 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode } from "./theme.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
AttentionItem,
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
HealthSnapshot,
HealthSummary,
LogEntry,
LogLevel,
ModelCatalogEntry,
NostrProfile,
PresenceEntry,
SessionsUsageResult,
@@ -27,8 +29,8 @@ import type {
SessionUsageTimeSeries,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
ToolsCatalogResult,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@@ -37,12 +39,16 @@ import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
settings: UiSettings;
password: string;
loginShowGatewayToken: boolean;
loginShowGatewayPassword: boolean;
tab: Tab;
onboarding: boolean;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
themeOrder: ThemeName[];
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
@@ -110,6 +116,26 @@ export type AppViewState = {
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
communicationsFormMode: "form" | "raw";
communicationsSearchQuery: string;
communicationsActiveSection: string | null;
communicationsActiveSubsection: string | null;
appearanceFormMode: "form" | "raw";
appearanceSearchQuery: string;
appearanceActiveSection: string | null;
appearanceActiveSubsection: string | null;
automationFormMode: "form" | "raw";
automationSearchQuery: string;
automationActiveSection: string | null;
automationActiveSubsection: string | null;
infrastructureFormMode: "form" | "raw";
infrastructureSearchQuery: string;
infrastructureActiveSection: string | null;
infrastructureActiveSubsection: string | null;
aiAgentsFormMode: "form" | "raw";
aiAgentsSearchQuery: string;
aiAgentsActiveSection: string | null;
aiAgentsActiveSubsection: string | null;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
@@ -155,6 +181,12 @@ export type AppViewState = {
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsHideCron: boolean;
sessionsSearchQuery: string;
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
sessionsSortDir: "asc" | "desc";
sessionsPage: number;
sessionsPageSize: number;
sessionsActionsOpenKey: string | null;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;
@@ -233,10 +265,13 @@ export type AppViewState = {
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHealth: HealthSummary | null;
debugModels: ModelCatalogEntry[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
@@ -256,11 +291,21 @@ export type AppViewState = {
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
attentionItems: AttentionItem[];
paletteOpen: boolean;
paletteQuery: string;
paletteActiveIndex: number;
streamMode: boolean;
overviewShowGatewayToken: boolean;
overviewShowGatewayPassword: boolean;
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;

View File

@@ -0,0 +1,34 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { titleForTab, type Tab } from "../navigation.js";
@customElement("dashboard-header")
export class DashboardHeader extends LitElement {
override createRenderRoot() {
return this;
}
@property() tab: Tab = "overview";
override render() {
const label = titleForTab(this.tab);
return html`
<div class="dashboard-header">
<div class="dashboard-header__breadcrumb">
<span
class="dashboard-header__breadcrumb-link"
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
>
OpenClaw
</span>
<span class="dashboard-header__breadcrumb-sep"></span>
<span class="dashboard-header__breadcrumb-current">${label}</span>
</div>
<div class="dashboard-header__actions">
<slot></slot>
</div>
</div>
`;
}
}

View File

@@ -58,3 +58,41 @@ export function parseList(input: string): string[] {
export function stripThinkingTags(value: string): string {
return stripAssistantInternalScaffolding(value);
}
export function formatCost(cost: number | null | undefined, fallback = "$0.00"): string {
if (cost == null || !Number.isFinite(cost)) {
return fallback;
}
if (cost === 0) {
return "$0.00";
}
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
if (cost < 1) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(2)}`;
}
export function formatTokens(tokens: number | null | undefined, fallback = "0"): string {
if (tokens == null || !Number.isFinite(tokens)) {
return fallback;
}
if (tokens < 1000) {
return String(Math.round(tokens));
}
if (tokens < 1_000_000) {
const k = tokens / 1000;
return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
}
const m = tokens / 1_000_000;
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
}
export function formatPercent(value: number | null | undefined, fallback = "—"): string {
if (value == null || !Number.isFinite(value)) {
return fallback;
}
return `${(value * 100).toFixed(1)}%`;
}

View File

@@ -50,6 +50,24 @@ export const icons = {
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
`,
sun: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
`,
moon: html`
<svg viewBox="0 0 24 24">
<path d="M12 3a6.5 6.5 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
`,
settings: html`
<svg viewBox="0 0 24 24">
<path
@@ -228,6 +246,201 @@ export const icons = {
/>
</svg>
`,
panelLeftClose: html`
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" stroke-linecap="round" />
<path d="M16 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
panelLeftOpen: html`
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" stroke-linecap="round" />
<path d="M14 10l3 2-3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
chevronDown: html`
<svg viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
chevronRight: html`
<svg viewBox="0 0 24 24">
<path d="M9 18l6-6-6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
externalLink: html`
<svg viewBox="0 0 24 24">
<path
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M15 3h6v6M10 14L21 3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
send: html`
<svg viewBox="0 0 24 24">
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
`,
stop: html`
<svg viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1" /></svg>
`,
pin: html`
<svg viewBox="0 0 24 24">
<line x1="12" x2="12" y1="17" y2="22" />
<path
d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
/>
</svg>
`,
pinOff: html`
<svg viewBox="0 0 24 24">
<line x1="2" x2="22" y1="2" y2="22" />
<line x1="12" x2="12" y1="17" y2="22" />
<path
d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0-.39.04"
/>
</svg>
`,
download: html`
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
`,
mic: html`
<svg viewBox="0 0 24 24">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
`,
micOff: html`
<svg viewBox="0 0 24 24">
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
`,
volume2: html`
<svg viewBox="0 0 24 24">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
`,
volumeOff: html`
<svg viewBox="0 0 24 24">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="22" x2="16" y1="9" y2="15" />
<line x1="16" x2="22" y1="9" y2="15" />
</svg>
`,
bookmark: html`
<svg viewBox="0 0 24 24"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /></svg>
`,
plus: html`
<svg viewBox="0 0 24 24">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
`,
terminal: html`
<svg viewBox="0 0 24 24">
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
`,
spark: html`
<svg viewBox="0 0 24 24">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"
/>
</svg>
`,
lobster: html`
<svg viewBox="0 0 120 120" fill="none">
<defs>
<linearGradient id="lob-g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d" />
<stop offset="100%" stop-color="#991b1b" />
</linearGradient>
</defs>
<path
d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z"
fill="url(#lob-g)"
/>
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#lob-g)" />
<path
d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z"
fill="url(#lob-g)"
/>
<path d="M45 15Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<path d="M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<circle cx="45" cy="35" r="6" fill="#050810" />
<circle cx="75" cy="35" r="6" fill="#050810" />
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
</svg>
`,
refresh: html`
<svg viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
`,
trash: html`
<svg viewBox="0 0 24 24">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
`,
eye: html`
<svg viewBox="0 0 24 24">
<path
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
/>
<circle cx="12" cy="12" r="3" />
</svg>
`,
eyeOff: html`
<svg viewBox="0 0 24 24">
<path
d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
/>
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path
d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
/>
<path d="m2 2 20 20" />
</svg>
`,
moreHorizontal: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="1.5" />
<circle cx="6" cy="12" r="1.5" />
<circle cx="18" cy="12" r="1.5" />
</svg>
`,
arrowUpDown: html`
<svg viewBox="0 0 24 24">
<path d="m21 16-4 4-4-4" />
<path d="M17 20V4" />
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
</svg>
`,
} as const;
export type IconName = keyof typeof icons;

View File

@@ -8,7 +8,19 @@ export const TAB_GROUPS = [
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
},
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
{ label: "settings", tabs: ["config", "debug", "logs"] },
{
label: "settings",
tabs: [
"config",
"communications",
"appearance",
"automation",
"infrastructure",
"aiAgents",
"debug",
"logs",
],
},
] as const;
export type Tab =
@@ -23,6 +35,11 @@ export type Tab =
| "nodes"
| "chat"
| "config"
| "communications"
| "appearance"
| "automation"
| "infrastructure"
| "aiAgents"
| "debug"
| "logs";
@@ -38,6 +55,11 @@ const TAB_PATHS: Record<Tab, string> = {
nodes: "/nodes",
chat: "/chat",
config: "/config",
communications: "/communications",
appearance: "/appearance",
automation: "/automation",
infrastructure: "/infrastructure",
aiAgents: "/ai-agents",
debug: "/debug",
logs: "/logs",
};
@@ -147,6 +169,16 @@ export function iconForTab(tab: Tab): IconName {
return "monitor";
case "config":
return "settings";
case "communications":
return "send";
case "appearance":
return "spark";
case "automation":
return "terminal";
case "infrastructure":
return "globe";
case "aiAgents":
return "brain";
case "debug":
return "bug";
case "logs":

View File

@@ -6,18 +6,20 @@ type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
import { isSupportedLocale } from "../i18n/index.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import type { ThemeMode } from "./theme.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
export type UiSettings = {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string;
};
@@ -106,11 +108,13 @@ export function loadSettings(): UiSettings {
token: loadSessionToken(defaultUrl),
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
};
@@ -120,6 +124,10 @@ export function loadSettings(): UiSettings {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>;
const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const settings = {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
@@ -140,10 +148,8 @@ export function loadSettings(): UiSettings {
? 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,
theme,
themeMode: mode,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:
@@ -158,6 +164,10 @@ export function loadSettings(): UiSettings {
: defaults.splitRatio,
navCollapsed:
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
navWidth:
typeof parsed.navWidth === "number" && parsed.navWidth >= 200 && parsed.navWidth <= 400
? parsed.navWidth
: defaults.navWidth,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed

View File

@@ -1,4 +1,4 @@
import type { ThemeMode } from "./theme.ts";
import type { ResolvedTheme } from "./theme.ts";
export type ThemeTransitionContext = {
element?: HTMLElement | null;
@@ -7,34 +7,10 @@ export type ThemeTransitionContext = {
};
export type ThemeTransitionOptions = {
nextTheme: ThemeMode;
nextTheme: ResolvedTheme;
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;
currentTheme?: ResolvedTheme | null;
};
const cleanupThemeTransition = (root: HTMLElement) => {
@@ -46,10 +22,12 @@ const cleanupThemeTransition = (root: HTMLElement) => {
export const startThemeTransition = ({
nextTheme,
applyTheme,
context,
currentTheme,
}: ThemeTransitionOptions) => {
if (currentTheme === nextTheme) {
// Even when the resolved palette is unchanged (e.g. system->dark on a dark OS),
// we still need to persist the user's explicit selection immediately.
applyTheme();
return;
}
@@ -60,50 +38,7 @@ export const startThemeTransition = ({
}
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;
}
// Theme updates should be visible immediately on click with no transition lag.
applyTheme();
cleanupThemeTransition(root);
};

View File

@@ -1,16 +1,74 @@
export type ThemeName = "claw" | "knot" | "dash";
export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export type ResolvedTheme =
| "dark"
| "light"
| "openknot"
| "openknot-light"
| "dash"
| "dash-light";
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash"]);
export const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
type ThemeSelection = { theme: ThemeName; mode: ThemeMode };
const LEGACY_MAP: Record<string, ThemeSelection> = {
defaultTheme: { theme: "claw", mode: "dark" },
docsTheme: { theme: "claw", mode: "light" },
lightTheme: { theme: "knot", mode: "dark" },
landingTheme: { theme: "knot", mode: "dark" },
newTheme: { theme: "knot", mode: "dark" },
dark: { theme: "claw", mode: "dark" },
light: { theme: "claw", mode: "light" },
openknot: { theme: "knot", mode: "dark" },
fieldmanual: { theme: "dash", mode: "dark" },
clawdash: { theme: "dash", mode: "light" },
system: { theme: "claw", mode: "system" },
};
export function prefersLightScheme(): boolean {
if (typeof globalThis.matchMedia !== "function") {
return false;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches;
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
export function resolveSystemTheme(): ResolvedTheme {
return prefersLightScheme() ? "light" : "dark";
}
export function parseThemeSelection(
themeRaw: unknown,
modeRaw: unknown,
): { theme: ThemeName; mode: ThemeMode } {
const theme = typeof themeRaw === "string" ? themeRaw : "";
const mode = typeof modeRaw === "string" ? modeRaw : "";
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
? (theme as ThemeName)
: (LEGACY_MAP[theme]?.theme ?? "claw");
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
? (mode as ThemeMode)
: (LEGACY_MAP[theme]?.mode ?? "system");
return { theme: normalizedTheme, mode: normalizedMode };
}
function resolveMode(mode: ThemeMode): "light" | "dark" {
if (mode === "system") {
return getSystemTheme();
return prefersLightScheme() ? "light" : "dark";
}
return mode;
}
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
const resolvedMode = resolveMode(mode);
if (theme === "claw") {
return resolvedMode === "light" ? "light" : "dark";
}
if (theme === "knot") {
return resolvedMode === "light" ? "openknot-light" : "openknot";
}
return resolvedMode === "light" ? "dash-light" : "dash";
}

39
ui/src/ui/tool-labels.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Map raw tool names to human-friendly labels for the chat UI.
* Unknown tools are title-cased with underscores replaced by spaces.
*/
export const TOOL_LABELS: Record<string, string> = {
exec: "Run Command",
bash: "Run Command",
read: "Read File",
write: "Write File",
edit: "Edit File",
apply_patch: "Apply Patch",
web_search: "Web Search",
web_fetch: "Fetch Page",
browser: "Browser",
message: "Send Message",
image: "Generate Image",
canvas: "Canvas",
cron: "Cron",
gateway: "Gateway",
nodes: "Nodes",
memory_search: "Search Memory",
memory_get: "Get Memory",
session_status: "Session Status",
sessions_list: "List Sessions",
sessions_history: "Session History",
sessions_send: "Send to Session",
sessions_spawn: "Spawn Session",
agents_list: "List Agents",
};
export function friendlyToolName(raw: string): string {
const mapped = TOOL_LABELS[raw];
if (mapped) {
return mapped;
}
// Title-case fallback: "some_tool_name" → "Some Tool Name"
return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -6,7 +6,7 @@ import type {
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
export type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type ChannelsStatusSnapshot = {
ts: number;
@@ -329,35 +329,6 @@ export type AgentsListResult = {
agents: GatewayAgentRow[];
};
export type ToolCatalogProfile = {
id: "minimal" | "coding" | "messaging" | "full";
label: string;
};
export type ToolCatalogEntry = {
id: string;
label: string;
description: string;
source: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">;
};
export type ToolCatalogGroup = {
id: string;
label: string;
source: "core" | "plugin";
pluginId?: string;
tools: ToolCatalogEntry[];
};
export type ToolsCatalogResult = {
agentId: string;
profiles: ToolCatalogProfile[];
groups: ToolCatalogGroup[];
};
export type AgentIdentityResult = {
agentId: string;
name: string;
@@ -510,58 +481,17 @@ export type CronStatus = {
nextWakeAtMs?: number | null;
};
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
export type CronSortDir = "asc" | "desc";
export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped";
export type CronRunsStatusValue = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
export type CronRunScope = "job" | "all";
export type CronRunLogEntry = {
ts: number;
jobId: string;
jobName?: string;
status?: CronRunsStatusValue;
status: "ok" | "error" | "skipped";
durationMs?: number;
error?: string;
summary?: string;
deliveryStatus?: CronDeliveryStatus;
deliveryError?: string;
delivered?: boolean;
runAtMs?: number;
nextRunAtMs?: number;
model?: string;
provider?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
};
sessionId?: string;
sessionKey?: string;
};
export type CronJobsListResult = {
jobs?: CronJob[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type CronRunsResult = {
entries?: CronRunLogEntry[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type SkillsStatusConfigCheck = {
path: string;
satisfied: boolean;
@@ -615,6 +545,44 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */
export type HealthSummary = {
ok: boolean;
ts: number;
durationMs: number;
heartbeatSeconds: number;
defaultAgentId: string;
agents: Array<{ id: string; name?: string }>;
sessions: {
path: string;
count: number;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
};
/** A model entry returned by the gateway model-catalog endpoint. */
export type ModelCatalogEntry = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
export type ToolCatalogProfile =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogProfile;
export type ToolCatalogEntry =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogEntry;
export type ToolCatalogGroup =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogGroup;
export type ToolsCatalogResult =
import("../../../src/gateway/protocol/schema/types.js").ToolsCatalogResult;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
@@ -625,3 +593,16 @@ export type LogEntry = {
message?: string | null;
meta?: Record<string, unknown> | null;
};
// ── Attention ───────────────────────────────────────
export type AttentionSeverity = "error" | "warning" | "info";
export type AttentionItem = {
severity: AttentionSeverity;
icon: string;
title: string;
description: string;
href?: string;
external?: boolean;
};