Merge c10e3d88b3 into 0761652701
This commit is contained in:
commit
fc0446143b
@ -34,6 +34,15 @@ const PAIRING_SCOPE = "operator.pairing";
|
|||||||
|
|
||||||
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
||||||
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
|
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]);
|
||||||
|
|
||||||
|
// Minimal method surface for public webchat clients.
|
||||||
|
// This is intentionally tiny; expand only when a concrete UI need is proven.
|
||||||
|
const WEBCHAT_ROLE_METHODS = new Set([
|
||||||
|
"agent.identity.get",
|
||||||
|
"chat.history",
|
||||||
|
"chat.send",
|
||||||
|
"chat.abort",
|
||||||
|
]);
|
||||||
const PAIRING_METHODS = new Set([
|
const PAIRING_METHODS = new Set([
|
||||||
"node.pair.request",
|
"node.pair.request",
|
||||||
"node.pair.list",
|
"node.pair.list",
|
||||||
@ -98,9 +107,19 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
|||||||
if (role === "node") return null;
|
if (role === "node") return null;
|
||||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "node") {
|
if (role === "node") {
|
||||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "webchat") {
|
||||||
|
if (WEBCHAT_ROLE_METHODS.has(method)) return null;
|
||||||
|
return errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`unauthorized method for role webchat: ${method}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (role !== "operator") {
|
if (role !== "operator") {
|
||||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
onceMessage,
|
onceMessage,
|
||||||
startGatewayServer,
|
startGatewayServer,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
|
rpcReq,
|
||||||
testState,
|
testState,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
@ -214,6 +215,48 @@ describe("gateway server auth/connect", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("webchat role authz", () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
|
let port: number;
|
||||||
|
let prevToken: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
|
||||||
|
port = await getFreePort();
|
||||||
|
server = await startGatewayServer(port);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("webchat role can connect but is restricted to chat methods", async () => {
|
||||||
|
const ws = await openWs(port);
|
||||||
|
const res = await connectReq(ws, {
|
||||||
|
token: "secret",
|
||||||
|
role: "webchat",
|
||||||
|
client: {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.WEBCHAT_UI,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "test",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const deny = await rpcReq(ws, "sessions.list", { limit: 1 });
|
||||||
|
expect(deny.ok).toBe(false);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("token auth", () => {
|
describe("token auth", () => {
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
let port: number;
|
let port: number;
|
||||||
|
|||||||
@ -333,7 +333,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roleRaw = connectParams.role ?? "operator";
|
const roleRaw = connectParams.role ?? "operator";
|
||||||
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
const role =
|
||||||
|
roleRaw === "operator" || roleRaw === "node" || roleRaw === "webchat" ? roleRaw : null;
|
||||||
if (!role) {
|
if (!role) {
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("invalid-role", {
|
setCloseCause("invalid-role", {
|
||||||
|
|||||||
@ -550,10 +550,16 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
it("escapes XML special characters in filenames to prevent injection", async () => {
|
it("escapes XML special characters in filenames to prevent injection", async () => {
|
||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
|
||||||
|
// Windows does not allow < or > (or many other chars) in filenames.
|
||||||
|
// Use a platform-appropriate name.
|
||||||
|
const platform = process.platform;
|
||||||
|
const rawName = platform === "win32" ? "file&test.txt" : "file<test>.txt";
|
||||||
|
|
||||||
// Create file with XML special characters in the name (what filesystem allows)
|
// Create file with XML special characters in the name (what filesystem allows)
|
||||||
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
|
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
|
||||||
// but we test that even if some slip through, they get escaped in output
|
// but we test that even if some slip through, they get escaped in output
|
||||||
const filePath = path.join(dir, "file<test>.txt");
|
const filePath = path.join(dir, rawName);
|
||||||
await fs.writeFile(filePath, "safe content");
|
await fs.writeFile(filePath, "safe content");
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
@ -574,11 +580,17 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const result = await applyMediaUnderstanding({ ctx, cfg });
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
expect(result.appliedFile).toBe(true);
|
expect(result.appliedFile).toBe(true);
|
||||||
// Verify XML special chars are escaped in the output
|
// Verify XML special chars are escaped in the output.
|
||||||
expect(ctx.Body).toContain("<");
|
if (platform === "win32") {
|
||||||
expect(ctx.Body).toContain(">");
|
// '&' should be escaped as & inside the name attribute.
|
||||||
// The raw < and > should not appear unescaped in the name attribute
|
expect(ctx.Body).toContain("&");
|
||||||
expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
|
expect(ctx.Body).not.toMatch(/name="[^"]*&[^"]*"/);
|
||||||
|
} else {
|
||||||
|
expect(ctx.Body).toContain("<");
|
||||||
|
expect(ctx.Body).toContain(">");
|
||||||
|
// The raw < and > should not appear unescaped in the name attribute
|
||||||
|
expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes MIME types to prevent attribute injection", async () => {
|
it("normalizes MIME types to prevent attribute injection", async () => {
|
||||||
|
|||||||
@ -607,3 +607,16 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public chat mode: minimal chrome for shareable/mobile use. */
|
||||||
|
.public-chat {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-chat .chat {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { loadNodes } from "./controllers/nodes";
|
|||||||
import { loadAgents } from "./controllers/agents";
|
import { loadAgents } from "./controllers/agents";
|
||||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||||
import { GatewayBrowserClient } from "./gateway";
|
import { GatewayBrowserClient } from "./gateway";
|
||||||
|
import { GATEWAY_CLIENT_NAMES } from "../../../src/gateway/protocol/client-info.js";
|
||||||
import type { EventLogEntry } from "./app-events";
|
import type { EventLogEntry } from "./app-events";
|
||||||
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
||||||
import type { Tab } from "./navigation";
|
import type { Tab } from "./navigation";
|
||||||
@ -116,12 +117,20 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
host.execApprovalError = null;
|
host.execApprovalError = null;
|
||||||
|
|
||||||
host.client?.stop();
|
host.client?.stop();
|
||||||
|
const clientName =
|
||||||
|
host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui";
|
||||||
|
|
||||||
|
const role = host.tab === "public-chat" ? "webchat" : undefined;
|
||||||
|
|
||||||
host.client = new GatewayBrowserClient({
|
host.client = new GatewayBrowserClient({
|
||||||
url: host.settings.gatewayUrl,
|
url: host.settings.gatewayUrl,
|
||||||
token: host.settings.token.trim() ? host.settings.token : undefined,
|
token: host.settings.token.trim() ? host.settings.token : undefined,
|
||||||
password: host.password.trim() ? host.password : undefined,
|
password: host.password.trim() ? host.password : undefined,
|
||||||
clientName: "moltbot-control-ui",
|
clientName,
|
||||||
mode: "webchat",
|
mode: "webchat",
|
||||||
|
role,
|
||||||
|
// Leave scopes undefined for operator (gateway will default). For webchat we want no scopes.
|
||||||
|
scopes: role === "webchat" ? [] : undefined,
|
||||||
onHello: (hello) => {
|
onHello: (hello) => {
|
||||||
host.connected = true;
|
host.connected = true;
|
||||||
host.lastError = null;
|
host.lastError = null;
|
||||||
|
|||||||
@ -86,6 +86,80 @@ import { loadLogs } from "./controllers/logs";
|
|||||||
const AVATAR_DATA_RE = /^data:/i;
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||||
|
|
||||||
|
function renderPublicChat(state: AppViewState) {
|
||||||
|
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
||||||
|
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||||
|
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||||
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="public-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,
|
||||||
|
// Public chat should not expose session lists.
|
||||||
|
sessions: null,
|
||||||
|
// Always "focus" (no sidebar/nav).
|
||||||
|
focusMode: true,
|
||||||
|
onRefresh: () => {
|
||||||
|
state.resetToolStream();
|
||||||
|
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||||
|
},
|
||||||
|
onToggleFocusMode: () => {
|
||||||
|
// no-op
|
||||||
|
},
|
||||||
|
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 }),
|
||||||
|
// Hide tool sidebar in public chat.
|
||||||
|
sidebarOpen: false,
|
||||||
|
assistantName: state.assistantName,
|
||||||
|
assistantAvatar: state.assistantAvatar,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||||
const list = state.agentsList?.agents ?? [];
|
const list = state.agentsList?.agents ?? [];
|
||||||
const parsed = parseAgentSessionKey(state.sessionKey);
|
const parsed = parseAgentSessionKey(state.sessionKey);
|
||||||
@ -102,6 +176,10 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderApp(state: AppViewState) {
|
export function renderApp(state: AppViewState) {
|
||||||
|
if (state.tab === "public-chat") {
|
||||||
|
return renderPublicChat(state);
|
||||||
|
}
|
||||||
|
|
||||||
const presenceCount = state.presenceEntries.length;
|
const presenceCount = state.presenceEntries.length;
|
||||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||||
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
||||||
|
|||||||
@ -53,6 +53,15 @@ export type GatewayBrowserClientOptions = {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
mode?: GatewayClientMode;
|
mode?: GatewayClientMode;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway role/scopes for this client.
|
||||||
|
* Control UI defaults to operator + admin scopes.
|
||||||
|
* Public WebChat will override this in a follow-up PR once the gateway enforces role allowlists.
|
||||||
|
*/
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
|
||||||
onHello?: (hello: GatewayHelloOk) => void;
|
onHello?: (hello: GatewayHelloOk) => void;
|
||||||
onEvent?: (evt: GatewayEventFrame) => void;
|
onEvent?: (evt: GatewayEventFrame) => void;
|
||||||
onClose?: (info: { code: number; reason: string }) => void;
|
onClose?: (info: { code: number; reason: string }) => void;
|
||||||
@ -132,8 +141,9 @@ export class GatewayBrowserClient {
|
|||||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||||
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
|
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
|
||||||
|
|
||||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
const scopes =
|
||||||
const role = "operator";
|
this.opts.scopes ?? ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
|
const role = this.opts.role ?? "operator";
|
||||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||||
let canFallbackToShared = false;
|
let canFallbackToShared = false;
|
||||||
let authToken = this.opts.token;
|
let authToken = this.opts.token;
|
||||||
|
|||||||
@ -129,6 +129,7 @@ describe("pathForTab", () => {
|
|||||||
describe("tabFromPath", () => {
|
describe("tabFromPath", () => {
|
||||||
it("returns tab for valid path", () => {
|
it("returns tab for valid path", () => {
|
||||||
expect(tabFromPath("/chat")).toBe("chat");
|
expect(tabFromPath("/chat")).toBe("chat");
|
||||||
|
expect(tabFromPath("/public/chat")).toBe("public-chat");
|
||||||
expect(tabFromPath("/overview")).toBe("overview");
|
expect(tabFromPath("/overview")).toBe("overview");
|
||||||
expect(tabFromPath("/sessions")).toBe("sessions");
|
expect(tabFromPath("/sessions")).toBe("sessions");
|
||||||
});
|
});
|
||||||
@ -148,6 +149,7 @@ describe("tabFromPath", () => {
|
|||||||
|
|
||||||
it("is case-insensitive", () => {
|
it("is case-insensitive", () => {
|
||||||
expect(tabFromPath("/CHAT")).toBe("chat");
|
expect(tabFromPath("/CHAT")).toBe("chat");
|
||||||
|
expect(tabFromPath("/Public/Chat")).toBe("public-chat");
|
||||||
expect(tabFromPath("/Overview")).toBe("overview");
|
expect(tabFromPath("/Overview")).toBe("overview");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type Tab =
|
|||||||
| "skills"
|
| "skills"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
| "chat"
|
| "chat"
|
||||||
|
| "public-chat"
|
||||||
| "config"
|
| "config"
|
||||||
| "debug"
|
| "debug"
|
||||||
| "logs";
|
| "logs";
|
||||||
@ -32,6 +33,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
skills: "/skills",
|
skills: "/skills",
|
||||||
nodes: "/nodes",
|
nodes: "/nodes",
|
||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
|
"public-chat": "/public/chat",
|
||||||
config: "/config",
|
config: "/config",
|
||||||
debug: "/debug",
|
debug: "/debug",
|
||||||
logs: "/logs",
|
logs: "/logs",
|
||||||
@ -103,6 +105,7 @@ export function inferBasePathFromPathname(pathname: string): string {
|
|||||||
export function iconForTab(tab: Tab): IconName {
|
export function iconForTab(tab: Tab): IconName {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "chat":
|
case "chat":
|
||||||
|
case "public-chat":
|
||||||
return "messageSquare";
|
return "messageSquare";
|
||||||
case "overview":
|
case "overview":
|
||||||
return "barChart";
|
return "barChart";
|
||||||
@ -133,6 +136,8 @@ export function titleForTab(tab: Tab) {
|
|||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "overview":
|
case "overview":
|
||||||
return "Overview";
|
return "Overview";
|
||||||
|
case "public-chat":
|
||||||
|
return "Chat";
|
||||||
case "channels":
|
case "channels":
|
||||||
return "Channels";
|
return "Channels";
|
||||||
case "instances":
|
case "instances":
|
||||||
@ -146,6 +151,7 @@ export function titleForTab(tab: Tab) {
|
|||||||
case "nodes":
|
case "nodes":
|
||||||
return "Nodes";
|
return "Nodes";
|
||||||
case "chat":
|
case "chat":
|
||||||
|
case "public-chat":
|
||||||
return "Chat";
|
return "Chat";
|
||||||
case "config":
|
case "config":
|
||||||
return "Config";
|
return "Config";
|
||||||
@ -162,6 +168,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "overview":
|
case "overview":
|
||||||
return "Gateway status, entry points, and a fast health read.";
|
return "Gateway status, entry points, and a fast health read.";
|
||||||
|
case "public-chat":
|
||||||
|
return "Public web chat (mobile-friendly).";
|
||||||
case "channels":
|
case "channels":
|
||||||
return "Manage channels and settings.";
|
return "Manage channels and settings.";
|
||||||
case "instances":
|
case "instances":
|
||||||
@ -176,6 +184,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
return "Paired devices, capabilities, and command exposure.";
|
return "Paired devices, capabilities, and command exposure.";
|
||||||
case "chat":
|
case "chat":
|
||||||
return "Direct gateway chat session for quick interventions.";
|
return "Direct gateway chat session for quick interventions.";
|
||||||
|
case "public-chat":
|
||||||
|
return "A minimal chat UI intended for sharing.";
|
||||||
case "config":
|
case "config":
|
||||||
return "Edit ~/.clawdbot/moltbot.json safely.";
|
return "Edit ~/.clawdbot/moltbot.json safely.";
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user