From 7e40147aa35d0ee39a097643f87b9f25a89926c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 22:05:17 +0100 Subject: [PATCH] fix: gate web chat/talk on mobile nodes --- .gitignore | 1 + CHANGELOG.md | 1 + src/gateway/server.ts | 43 ++++++ ui/src/styles/components.css | 198 +++++++++++++++++++++------ ui/src/ui/app-render.ts | 12 ++ ui/src/ui/app.ts | 32 ++++- ui/src/ui/controllers/nodes.ts | 11 +- ui/src/ui/navigation.browser.test.ts | 4 +- ui/src/ui/views/chat.ts | 86 ++++++++---- 9 files changed, 314 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index 972264bcc..cc1a82e2a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .worktrees/ .DS_Store **/.DS_Store +ui/src/ui/__screenshots__/ # Bun build artifacts *.bun-build diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d14f694..d017ef798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles. - Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue). - Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message. +- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send. - Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android). - iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs). - iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech. diff --git a/src/gateway/server.ts b/src/gateway/server.ts index c6f259b96..18960a226 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1648,6 +1648,19 @@ export async function startGatewayServer( let bridge: Awaited> | null = null; const bridgeNodeSubscriptions = new Map>(); const bridgeSessionSubscribers = new Map>(); + + const isMobilePlatform = (platform: unknown): boolean => { + const p = typeof platform === "string" ? platform.trim().toLowerCase() : ""; + if (!p) return false; + return ( + p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android") + ); + }; + + const hasConnectedMobileNode = (): boolean => { + const connected = bridge?.listConnected?.() ?? []; + return connected.some((n) => isMobilePlatform(n.platform)); + }; try { await new Promise((resolve, reject) => { const onError = (err: NodeJS.ErrnoException) => { @@ -4094,6 +4107,21 @@ export async function startGatewayServer( break; } case "chat.send": { + if ( + client && + isWebchatConnect(client.connect) && + !hasConnectedMobileNode() + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "web chat disabled: no connected iOS/Android nodes", + ), + ); + break; + } const params = (req.params ?? {}) as Record; if (!validateChatSendParams(params)) { respond( @@ -4645,6 +4673,21 @@ export async function startGatewayServer( break; } case "talk.mode": { + if ( + client && + isWebchatConnect(client.connect) && + !hasConnectedMobileNode() + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "talk disabled: no connected iOS/Android nodes", + ), + ); + break; + } const params = (req.params ?? {}) as Record; if (!validateTalkModeParams(params)) { respond( diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 4b6ccc8dc..a44e9a824 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -361,64 +361,166 @@ background: rgba(0, 0, 0, 0.2); } -.messages { - display: grid; - gap: 10px; - max-height: 60vh; - overflow: auto; - padding: 8px; - min-width: 0; - border-radius: 12px; - background: rgba(0, 0, 0, 0.2); -} - -.chat-messages { - margin-top: 8px; - padding: 6px; -} - -.msg { - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.2); - border-radius: 14px; - padding: 10px 12px; - min-width: 0; -} - -.msg .meta { - font-size: 12px; - color: var(--muted); +.chat-header { display: flex; justify-content: space-between; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.chat-header__left { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.chat-header__right { + display: flex; + align-items: center; gap: 10px; - margin-bottom: 6px; } -.msg.user { - border-color: rgba(255, 255, 255, 0.14); +.chat-session { + min-width: 240px; } -.msg.assistant { - border-color: rgba(255, 122, 61, 0.25); - background: rgba(255, 122, 61, 0.08); +.chat-thread { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 60vh; + overflow: auto; + padding: 14px 12px; + min-width: 0; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.18) 0%, + rgba(0, 0, 0, 0.26) 100% + ); } -.msgContent { +:root[data-theme="light"] .chat-thread { + border-color: rgba(16, 24, 40, 0.12); + background: linear-gradient( + 180deg, + rgba(16, 24, 40, 0.03) 0%, + rgba(16, 24, 40, 0.06) 100% + ); +} + +.chat-line { + display: flex; +} + +.chat-line.user { + justify-content: flex-end; +} + +.chat-line.assistant, +.chat-line.other { + justify-content: flex-start; +} + +.chat-msg { + display: grid; + gap: 6px; + max-width: min(720px, 82%); +} + +.chat-line.user .chat-msg { + justify-items: end; +} + +.chat-bubble { + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.24); + border-radius: 18px; + padding: 10px 12px; + min-width: 0; + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22); +} + +:root[data-theme="light"] .chat-bubble { + background: rgba(255, 255, 255, 0.85); + box-shadow: 0 12px 26px rgba(16, 24, 40, 0.08); +} + +.chat-line.user .chat-bubble { + border-color: rgba(255, 122, 61, 0.35); + background: linear-gradient( + 135deg, + rgba(255, 122, 61, 0.24) 0%, + rgba(255, 122, 61, 0.12) 100% + ); +} + +.chat-line.assistant .chat-bubble { + border-color: rgba(54, 207, 201, 0.16); + background: linear-gradient( + 135deg, + rgba(54, 207, 201, 0.08) 0%, + rgba(0, 0, 0, 0.22) 100% + ); +} + +:root[data-theme="light"] .chat-line.assistant .chat-bubble { + background: linear-gradient( + 135deg, + rgba(27, 185, 177, 0.12) 0%, + rgba(255, 255, 255, 0.85) 100% + ); +} + +@keyframes chatStreamPulse { + 0% { + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 0 rgba(54, 207, 201, 0); + } + 60% { + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 6px rgba(54, 207, 201, 0.06); + } + 100% { + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 0 rgba(54, 207, 201, 0); + } +} + +.chat-bubble.streaming { + border-color: rgba(54, 207, 201, 0.32); + animation: chatStreamPulse 1.6s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .chat-bubble.streaming { + animation: none; + } +} + +.chat-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } -.compose { - display: grid; - gap: 10px; +.chat-stamp { + font-size: 11px; + color: var(--muted); +} + +.chat-line.user .chat-stamp { + text-align: right; } .chat-compose { - margin-top: 8px; + margin-top: 12px; + display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; - gap: 8px; + gap: 10px; } .chat-compose__field { @@ -426,8 +528,18 @@ } .chat-compose__field textarea { - min-height: 120px; - padding: 8px 10px; + min-height: 72px; + padding: 10px 12px; + border-radius: 14px; + resize: vertical; + white-space: pre-wrap; + font-family: var(--font-body); + line-height: 1.45; +} + +.chat-compose__field textarea:disabled { + opacity: 0.7; + cursor: not-allowed; } .chat-compose__actions { @@ -436,6 +548,10 @@ } @media (max-width: 900px) { + .chat-session { + min-width: 200px; + } + .chat-compose { grid-template-columns: 1fr; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c82894b95..61949c540 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -147,6 +147,16 @@ export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; + const hasConnectedMobileNode = state.nodes.some((n) => { + if (!Boolean(n.connected)) return false; + const p = typeof n.platform === "string" ? n.platform.trim().toLowerCase() : ""; + return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android"); + }); + const chatDisabledReason = !state.connected + ? "Disconnected from gateway." + : hasConnectedMobileNode + ? null + : "No connected iOS/Android node — Web Chat + Talk are disabled."; return html`
@@ -322,6 +332,8 @@ export function renderApp(state: AppViewState) { stream: state.chatStream, draft: state.chatMessage, connected: state.connected, + canSend: state.connected && hasConnectedMobileNode, + disabledReason: chatDisabledReason, onRefresh: () => loadChatHistory(state), onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 2c3e7ba3d..42fb93741 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -168,6 +168,7 @@ export class ClawdisApp extends LitElement { client: GatewayBrowserClient | null = null; private chatScrollFrame: number | null = null; + private nodesPollInterval: number | null = null; basePath = ""; private popStateHandler = () => this.onPopState(); private themeMedia: MediaQueryList | null = null; @@ -185,10 +186,12 @@ export class ClawdisApp extends LitElement { this.attachThemeListener(); window.addEventListener("popstate", this.popStateHandler); this.connect(); + this.startNodesPolling(); } disconnectedCallback() { window.removeEventListener("popstate", this.popStateHandler); + this.stopNodesPolling(); this.detachThemeListener(); super.disconnectedCallback(); } @@ -221,6 +224,7 @@ export class ClawdisApp extends LitElement { this.connected = true; this.hello = hello; this.applySnapshot(hello); + void loadNodes(this, { quiet: true }); void this.refreshActiveTab(); }, onClose: ({ code, reason }) => { @@ -239,12 +243,37 @@ export class ClawdisApp extends LitElement { if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame); this.chatScrollFrame = requestAnimationFrame(() => { this.chatScrollFrame = null; - const container = this.querySelector(".messages") as HTMLElement | null; + const container = this.querySelector(".chat-thread") as HTMLElement | null; if (!container) return; container.scrollTop = container.scrollHeight; }); } + private startNodesPolling() { + if (this.nodesPollInterval != null) return; + this.nodesPollInterval = window.setInterval( + () => void loadNodes(this, { quiet: true }), + 5000, + ); + } + + private stopNodesPolling() { + if (this.nodesPollInterval == null) return; + clearInterval(this.nodesPollInterval); + this.nodesPollInterval = null; + } + + private hasConnectedMobileNode() { + return this.nodes.some((n) => { + if (!Boolean(n.connected)) return false; + const p = + typeof n.platform === "string" ? n.platform.trim().toLowerCase() : ""; + return ( + p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android") + ); + }); + } + private onEvent(evt: GatewayEventFrame) { this.eventLog = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, @@ -427,6 +456,7 @@ export class ClawdisApp extends LitElement { } async handleSendChat() { + if (!this.connected || !this.hasConnectedMobileNode()) return; await sendChat(this); void loadChatHistory(this); } diff --git a/ui/src/ui/controllers/nodes.ts b/ui/src/ui/controllers/nodes.ts index 3fa39afd6..2a6a8219d 100644 --- a/ui/src/ui/controllers/nodes.ts +++ b/ui/src/ui/controllers/nodes.ts @@ -8,19 +8,22 @@ export type NodesState = { lastError: string | null; }; -export async function loadNodes(state: NodesState) { +export async function loadNodes( + state: NodesState, + opts?: { quiet?: boolean }, +) { if (!state.client || !state.connected) return; + if (state.nodesLoading) return; state.nodesLoading = true; - state.lastError = null; + if (!opts?.quiet) state.lastError = null; try { const res = (await state.client.request("node.list", {})) as { nodes?: Array>; }; state.nodes = Array.isArray(res.nodes) ? res.nodes : []; } catch (err) { - state.lastError = String(err); + if (!opts?.quiet) state.lastError = String(err); } finally { state.nodesLoading = false; } } - diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index de8dd84ec..da9d34b38 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -68,7 +68,7 @@ describe("control UI routing", () => { const app = mountApp("/chat"); await app.updateComplete; - const initialContainer = app.querySelector(".messages") as HTMLElement | null; + const initialContainer = app.querySelector(".chat-thread") as HTMLElement | null; expect(initialContainer).not.toBeNull(); if (!initialContainer) return; initialContainer.style.maxHeight = "180px"; @@ -83,7 +83,7 @@ describe("control UI routing", () => { await app.updateComplete; await nextFrame(); - const container = app.querySelector(".messages") as HTMLElement | null; + const container = app.querySelector(".chat-thread") as HTMLElement | null; expect(container).not.toBeNull(); if (!container) return; const maxScroll = container.scrollHeight - container.clientHeight; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 6e952d8f6..4ed1a279b 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -10,58 +10,90 @@ export type ChatProps = { stream: string | null; draft: string; connected: boolean; + canSend: boolean; + disabledReason: string | null; onRefresh: () => void; onDraftChange: (next: string) => void; onSend: () => void; }; export function renderChat(props: ChatProps) { + const canInteract = props.connected; + const canCompose = props.canSend && !props.sending; + const composePlaceholder = (() => { + if (!props.connected) return "Connect to the gateway to start chatting…"; + if (!props.canSend) return "Connect an iOS/Android node to enable Web Chat + Talk…"; + return "Message (⌘↩ to send)"; + })(); + return html` -
-
-
-