From de914f4b16c8d95cc587ea75646973ea2a4c40a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Mon, 9 Nov 2026 10:53:40 +0000 Subject: [PATCH] Activity: one stream at a time, none while hidden; the header by the chat's width The events stream is held while it opens, so a reconnect and the window showing again cannot open two; while the window is hidden in the tray it is not reopened (the server polls for each), and showing the window opens it again. The Thinking switch says 'Think before answering' in the header too: 'Thinking' is the catalogues' word for a reply in progress. The chat's header restacks by the chat column's own width (a container query at --bp-narrow), so a chat beside the activity panel keeps its title. Co-Authored-By: Claude Opus 5.5 --- ui/app.css | 13 +++++++++++++ ui/js/activity.js | 26 +++++++++++++++++++++----- ui/js/chat.js | 2 -- ui/js/models.js | 3 +-- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/ui/app.css b/ui/app.css index 47c899a..cd5c3b1 100644 --- a/ui/app.css +++ b/ui/app.css @@ -1053,6 +1053,19 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo .activity { grid-area: main; z-index: var(--z-panel); border-left: 0; } } +/* The chat's header by the chat's own width, not the window's: with the activity panel or the + viewer beside it, a wide window still leaves a narrow chat, and its controls get a row of their + own there as on a narrow window. 48rem is --bp-narrow again. */ +.main { container: chat / inline-size; } +@container chat (max-width: 48rem) { + .head { padding: var(--sp-2) var(--sp-3) 0; grid-template-columns: minmax(0, 1fr) auto; } + .head__place { grid-column: 1 / -1; grid-row: 2; padding-bottom: var(--sp-2); } + .head__place:empty { display: none; } + .head__tools { grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; padding-bottom: var(--sp-2); } + .head__place:empty + .head__tools { grid-row: 2; } + .head__tools:empty { display: none; } +} + /* 48rem (--bp-narrow): the sidebar is a drawer, opened from the title bar. */ @media (max-width: 48rem) { :root { --side-btn-w: var(--titlebar-h); } diff --git a/ui/js/activity.js b/ui/js/activity.js index 1846f9c..a00142b 100644 --- a/ui/js/activity.js +++ b/ui/js/activity.js @@ -169,6 +169,11 @@ function stop() { } async function listenTo(id) { + // Held while the stream opens, so nothing opens a second one meanwhile. + const pending = { close() {} } + stream = pending + let ended = false + let handleId = null try { const handle = await follow(`/chats/${enc(id)}/activity/events`, { onFrame: (event, frame) => { @@ -179,10 +184,14 @@ async function listenTo(id) { // The server closes the stream after a while (`close`): it is opened again at once. A broken // one is opened again later, more slowly each time -- polling, in effect, while it fails. onEnd: (err) => { + ended = true if (chat?.id !== id) return - stream = null + if (stream === pending || stream?.id === handleId) stream = null + // Not while the window is hidden (in the tray): it is opened again when it shows. + if (document.visibilityState === "hidden") return failures = err ? failures + 1 : 0 retry = setTimeout(() => { + retry = 0 if (chat?.id === id && !stream) { if (err && panelOpen()) read(false) listenTo(id) @@ -190,12 +199,17 @@ async function listenTo(id) { }, err ? Math.min(60000, 5000 * failures) : 500) }, }) - if (chat?.id !== id) handle.close() - else stream = handle + handleId = handle.id + if (chat?.id !== id || stream !== pending) handle.close() + else if (!ended) stream = handle } catch { + if (stream === pending) stream = null if (chat?.id !== id) return failures += 1 - retry = setTimeout(() => chat?.id === id && listenTo(id), Math.min(60000, 5000 * failures)) + retry = setTimeout(() => { + retry = 0 + if (chat?.id === id && !stream) listenTo(id) + }, Math.min(60000, 5000 * failures)) } } @@ -662,6 +676,8 @@ export function initActivity({ onLayout }) { true, ) document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible" && chat && panelOpen()) read(false) + if (document.visibilityState !== "visible" || !chat) return + if (!stream && !retry) listenTo(chat.id) + if (panelOpen()) read(false) }) } diff --git a/ui/js/chat.js b/ui/js/chat.js index f711aa6..3090ec1 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -832,8 +832,6 @@ export function newChat(kind) { const showLevels = (id) => { const model = models.find((m) => m.id === id) const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" }) - const switchOnly = levelsOf(model).control === "switch" - effortField.children[0].textContent = switchOnly ? t("Thinking") : t("Reasoning effort") effortField.children[0].htmlFor = "new-effort" effortField.children[1].replaceWith(control || h("span", { id: "new-effort" })) effortField.hidden = !control diff --git a/ui/js/models.js b/ui/js/models.js index 4e552be..6919b28 100644 --- a/ui/js/models.js +++ b/ui/js/models.js @@ -165,9 +165,8 @@ export function effortControl({ model, value, onChange, id, compact }) { "label", { class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") }, box, - h("span", { class: "toggle__label", text: compact ? t("Thinking") : t("Think before answering") }), + h("span", { class: "toggle__label", text: t("Think before answering") }), ) - if (compact) box.setAttribute("aria-label", t("Think before answering")) box.addEventListener("change", () => { current = box.checked ? on : "off" onChange?.(current)