diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index c2a5c6fe3..878e89e30 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -607,3 +607,16 @@
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;
+}
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index 422af6863..6cf9dcfd0 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -86,6 +86,80 @@ import { loadLogs } from "./controllers/logs";
const AVATAR_DATA_RE = /^data:/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`
+
+ ${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,
+ })}
+
+ `;
+}
+
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
@@ -102,6 +176,10 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
}
export function renderApp(state: AppViewState) {
+ if (state.tab === "public-chat") {
+ return renderPublicChat(state);
+ }
+
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts
index 7b15deb4a..eac66589d 100644
--- a/ui/src/ui/navigation.test.ts
+++ b/ui/src/ui/navigation.test.ts
@@ -129,6 +129,7 @@ describe("pathForTab", () => {
describe("tabFromPath", () => {
it("returns tab for valid path", () => {
expect(tabFromPath("/chat")).toBe("chat");
+ expect(tabFromPath("/public/chat")).toBe("public-chat");
expect(tabFromPath("/overview")).toBe("overview");
expect(tabFromPath("/sessions")).toBe("sessions");
});
@@ -148,6 +149,7 @@ describe("tabFromPath", () => {
it("is case-insensitive", () => {
expect(tabFromPath("/CHAT")).toBe("chat");
+ expect(tabFromPath("/Public/Chat")).toBe("public-chat");
expect(tabFromPath("/Overview")).toBe("overview");
});
});
diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts
index 966abec96..1b48a9f43 100644
--- a/ui/src/ui/navigation.ts
+++ b/ui/src/ui/navigation.ts
@@ -19,6 +19,7 @@ export type Tab =
| "skills"
| "nodes"
| "chat"
+ | "public-chat"
| "config"
| "debug"
| "logs";
@@ -32,6 +33,7 @@ const TAB_PATHS: Record = {
skills: "/skills",
nodes: "/nodes",
chat: "/chat",
+ "public-chat": "/public/chat",
config: "/config",
debug: "/debug",
logs: "/logs",
@@ -103,6 +105,7 @@ export function inferBasePathFromPathname(pathname: string): string {
export function iconForTab(tab: Tab): IconName {
switch (tab) {
case "chat":
+ case "public-chat":
return "messageSquare";
case "overview":
return "barChart";
@@ -133,6 +136,8 @@ export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
+ case "public-chat":
+ return "Chat";
case "channels":
return "Channels";
case "instances":
@@ -146,6 +151,7 @@ export function titleForTab(tab: Tab) {
case "nodes":
return "Nodes";
case "chat":
+ case "public-chat":
return "Chat";
case "config":
return "Config";
@@ -162,6 +168,8 @@ export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
+ case "public-chat":
+ return "Public web chat (mobile-friendly).";
case "channels":
return "Manage channels and settings.";
case "instances":
@@ -176,6 +184,8 @@ export function subtitleForTab(tab: Tab) {
return "Paired devices, capabilities, and command exposure.";
case "chat":
return "Direct gateway chat session for quick interventions.";
+ case "public-chat":
+ return "A minimal chat UI intended for sharing.";
case "config":
return "Edit ~/.clawdbot/moltbot.json safely.";
case "debug":