From 918f65b7ad644ce982e0e3ce0d1140b7bd69383a Mon Sep 17 00:00:00 2001 From: mattshax Date: Tue, 8 Sep 2026 03:32:20 +0000 Subject: [PATCH] fix(mobile): settings and help work on a phone, and three chat fixes Four things a phone found. The section rail on Settings and Help is 220px beside a scrolling article; on a phone it took most of the width, and with the page itself no longer scrolling the article had nowhere to go, so the page looked frozen. The rail is now a horizontal strip of section chips above the article, and every view scrolls inside itself. The chat controls float over the package's header, and on a phone the model picker grew under them. Every label drops to its icon there and the picker is given a ceiling and allowed to truncate. A retried turn appended a second answer to the first, because the client accumulates content events and cannot unsay them. A turn that has already spoken is no longer retried; the error is surfaced instead. And a provider that refuses a token cap is now remembered on disk, so only the first turn a deployment ever runs pays for learning it, and that retry keeps streaming, since a rejected parameter is not a streaming failure and answering without it left the reader watching a still Thinking line. Voice opens at top level rather than in an iframe: the deployment is its own platform session on its own domain, so the frame asked for a second sign-in that mobile browsers cannot carry a cookie into. Serving it from the Studio's own origin needs a proxy and a base path in Unmute's frontend build; the panel says what it is until that lands. --- server/src/chat/routes.ts | 36 +++++++++++++++++--- web/src/components/VoiceOverlay.tsx | 34 +++++++++++-------- web/src/styles.css | 52 ++++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 22 deletions(-) diff --git a/server/src/chat/routes.ts b/server/src/chat/routes.ts index 149046b..b255404 100644 --- a/server/src/chat/routes.ts +++ b/server/src/chat/routes.ts @@ -1,8 +1,10 @@ import type { FastifyInstance } from 'fastify' +import fs from 'node:fs' +import path from 'node:path' import { suggestNext } from './suggest.js' import { getConversation } from '../conversations.js' import { listRuns } from '../runs.js' -import { GATEWAY_BASE, MAX_TOOL_ITERATIONS, SIDECAR_ENDPOINT } from '../config.js' +import { GATEWAY_BASE, MAX_TOOL_ITERATIONS, SIDECAR_ENDPOINT, INDEX_BASE } from '../config.js' import { aiHealth, gatewayConfigured, isSidecarModel, listModels, listSidecarModels, modelFailure, probeProvider, probeableProvider, extractUnlockUrl, invalidateProviderProbes, sidecarConfigured, sidecarTarget, StreamedTurnError, streamTurn, WireMessage, WireToolCall, type TokenUsage } from './gateway.js' import { TOOL_CALLS, TOOL_SPECS, activeToolSpecs, activeToolSpecsWithRemote, commandFor, customToolSpecs, executeTool, expandSlashCommand, skillToolSpec } from './tools.js' import { systemPrompt } from './context.js' @@ -220,7 +222,18 @@ function estimateTokens(messages: WireMessage[]): number { * request; the process lifetime is the right scope, since a model that * starts accepting the cap gets it back on the next deployment. */ -const noTokenCap = new Set() +const NO_CAP_FILE = path.join(INDEX_BASE, 'no-token-cap.json') +const noTokenCap = ((): Set => { + // Remembered on disk: a provider that refuses a token cap refuses it + // every time, and re-learning it after each restart costs the first + // turn of a conversation a failed attempt. + try { return new Set(JSON.parse(fs.readFileSync(NO_CAP_FILE, 'utf8')) as string[]) } catch { return new Set() } +})() +function rememberNoTokenCap(model: string): void { + if (!model || noTokenCap.has(model)) return + noTokenCap.add(model) + try { fs.writeFileSync(NO_CAP_FILE, JSON.stringify([...noTokenCap])) } catch { /* memory still holds it for this process */ } +} const BASE_ATTRS = 'Path=/; HttpOnly; Secure; SameSite=None' @@ -956,9 +969,16 @@ ${ctx}` : ctx // Once a provider rejects the token cap, every later turn of this // reply skips it up front instead of paying a failed attempt each. let dropTokenCap = false + // The unstreamed retry exists for serves that fail a generation after + // the 200 is committed. A rejected parameter is not that: the turn + // never started, and answering it without streaming leaves the reader + // watching a still "Thinking" line for the whole reply. Only a real + // generation failure gives up streaming. + let retryUnstreamed = false const turnWithRetry = async (payload: Record) => { for (let attempt = 0; ; attempt++) { - const p: Record = attempt === 0 && !noStream ? { ...payload } : { ...payload, stream: false } + const streamThis = !noStream && (attempt === 0 || !retryUnstreamed) + const p: Record = streamThis ? { ...payload } : { ...payload, stream: false } const emulating = foldSystem && Array.isArray(p.tools) && (p.tools as unknown[]).length > 0 if (foldSystem) { p.messages = emulatedPrompt( @@ -1027,12 +1047,18 @@ ${ctx}` : ctx const generationError = err instanceof StreamedTurnError || /error occurred while generating/i.test(errText) || rejectedTokenCap - if (generationError && attempt < 2 && !abort.signal.aborted) { + // A retry after text has already reached the reader appends a + // second answer to the first: the client accumulates content + // events and has no way to unsay them. So a turn that has + // spoken is never retried; the error is surfaced instead. + const spoken = finalContent.length > 0 + if (generationError && attempt < 2 && !abort.signal.aborted && !spoken) { if (!(err instanceof StreamedTurnError)) dropTokenCap = true + if (!rejectedTokenCap) retryUnstreamed = true // Only an explicit parameter rejection is worth remembering: // the masked generation error also covers sampling failures, // which say nothing about whether the cap is supported. - if (rejectedTokenCap) noTokenCap.add(String(body.model ?? '')) + if (rejectedTokenCap) rememberNoTokenCap(String(body.model ?? '')) req.log.warn({ attempt, dropTokenCap, err: String(err?.message ?? err) }, 'serve failed generating; retrying turn') continue } diff --git a/web/src/components/VoiceOverlay.tsx b/web/src/components/VoiceOverlay.tsx index 3e963c8..06e9256 100644 --- a/web/src/components/VoiceOverlay.tsx +++ b/web/src/components/VoiceOverlay.tsx @@ -1,21 +1,29 @@ /** - * Voice conversations, as a feature preview: the Unmute deployment's own - * interface, in an overlay over the chat. Unmute is a separate service - * (speech recognition, turn-taking, synthesis, and the model behind them, - * which is this Studio's own assistant on the /v1 endpoint), so the - * overlay is an iframe with microphone permission delegated to it. The - * transcript stays with Unmute for now; recording it as a Studio - * conversation is the next step once the voice turns flow through here. + * Voice conversations, as a feature preview. The Unmute deployment is its + * own platform session on its own domain, so an iframe of it is a + * cross-site frame: the session proxy asks for its own sign-in inside the + * frame, and mobile browsers block the cookie that would carry the + * existing session across. Opening it at top level is where a platform + * session works, so the button does that, and this panel explains what + * is about to happen and what the voice can reach. + * + * Serving it from the Studio's own origin instead is the better answer + * and needs a proxy plus a base path in Unmute's frontend build; until + * that lands, one tab is honest and works everywhere. */ export function VoiceOverlay({ url, onClose }: { url: string; onClose: () => void }) { return ( -
-
- Voice ยท feature preview - open in a new tab - +
+

Talk with the assistant

+

+ Voice opens in its own tab: it listens, decides when you have finished a thought, answers aloud, and can be + interrupted. The assistant behind it is this one, with the same knowledge base, tools, and workflows. +

+

Feature preview. The conversation stays in that tab and is not recorded here yet.

+
+ Open voice +
-