From d7f89ee2578786dd0ac1b9430f226a9bab046ef7 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 22:15:45 +0100 Subject: [PATCH] Fix stale PWA service worker after updates; collapsible task list; short screens Installed apps checked their service worker at /sw-.js. That file no longer exists after an update and the static fallback answered with index.html, so the update check failed and the old worker kept serving the previous app shell. The old frontend crashed on the new image references (base64Size of a missing `data`), leaving a blank page on phones. Old service worker URLs now get the clean-up worker (/sw.js), which unregisters and reloads; missing scripts and styles answer 404 instead of HTML. - Task list above the composer folds to one line (progress, current task), remembered per device. - max-height: 560px layout (square phones such as the Clicks Communicator, landscape): tighter top bar, composer and new-chat wizard. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +++++ server/src/api.rs | 36 ++++++++- web/src/components/ChatView.tsx | 30 +++++-- web/src/prefs.ts | 18 +++++ web/src/styles.css | 134 ++++++++++++++++++++++++++++++++ web/src/whatsnew.ts | 4 + 6 files changed, 232 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0b6ac..72486a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Collapsible task list:** the model's task list above the composer folds to one line + (progress and current task) with a click on its heading; the choice is kept per device. +- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar, + composer and new-chat wizard, so the chat keeps most of the height. + +### Fixed + +- **Blank page on phones after the update:** installed apps (PWA) checked their service worker + at its old URL, got the web page instead of a script and therefore kept the previous app + version forever. With the new image format of v202611-00010 that old version crashed after + showing the chat for a moment. Old service worker URLs now get the clean-up worker, which + removes the stale worker and reloads the current version. Missing scripts/styles answer + 404 instead of the web page. + ## [v202611-00010] – 2026-11-09 ### Changed diff --git a/server/src/api.rs b/server/src/api.rs index 0e65e33..1770efe 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -172,8 +172,7 @@ pub fn router(app: AppState) -> Router { .find(|p| p.join("index.html").exists()) .unwrap_or_else(|| PathBuf::from("./web/dist")) }); - let index = web_dir.join("index.html"); - let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index)); + let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone())); Router::new() .nest("/api", api) @@ -186,6 +185,39 @@ pub fn router(app: AppState) -> Router { .with_state(app) } +/// Paths that are not a file of the web build. Usually a client-side route → index.html, +/// except for: +/// - `/sw-.js` of an earlier build: installed apps check their service worker at +/// that URL for updates. Answering with index.html (not a script) makes the update +/// fail, so the old worker kept serving the old app shell forever – which broke when +/// the API changed. They get the kill switch /sw.js instead: it removes the old worker +/// and reloads, and the page then registers the current worker. +/// - other missing scripts/styles (old chunks): 404 instead of HTML. +fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter { + use axum::http::{header, StatusCode}; + use axum::response::IntoResponse; + axum::routing::any(move |uri: axum::http::Uri| { + let web_dir = web_dir.clone(); + async move { + let path = uri.path(); + let name = path.trim_start_matches('/'); + if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') { + return match tokio::fs::read(web_dir.join("sw.js")).await { + Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } + if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) { + return StatusCode::NOT_FOUND.into_response(); + } + match tokio::fs::read(web_dir.join("index.html")).await { + Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } + } + }) +} + /// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default /// predicate), and so are binary downloads (already compressed or large). fn compression() -> tower_http::compression::CompressionLayer { diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 870ebe0..29c832e 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -7,9 +7,9 @@ import { useModels } from "../hooks"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { dismissError, loadSession, loadSessions, useStore } from "../store"; -import type { Aggregate, ImageData, Message, Part, Session } from "../types"; +import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; -import { useReasoningMode } from "../prefs"; +import { useReasoningMode, useTodosCollapsed } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; import { RenameDialog } from "./RenameDialog"; @@ -503,10 +503,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string 0 || questions.length > 0} /> )} {openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && ( -
-
{t("tool.todos")}
- -
+ )} {permissions.map((p) => ( @@ -633,3 +630,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) { ); } + +/** Tasks of the model above the composer; folds to one line (current task, progress). */ +function TodoPanel({ todos }: { todos: Todo[] }) { + const t = useT(); + const [collapsed, setCollapsed] = useTodosCollapsed(); + const done = todos.filter((x) => x.status === "completed").length; + const current = todos.find((x) => x.status === "in_progress"); + return ( +
+ + {!collapsed && } +
+ ); +} diff --git a/web/src/prefs.ts b/web/src/prefs.ts index 52b1ee3..449dd40 100644 --- a/web/src/prefs.ts +++ b/web/src/prefs.ts @@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode { getReasoningMode, ); } + +const TODOS_KEY = "codecraft.todosCollapsed"; + +/** Task list above the composer folded to a one-line summary (per device). */ +export function useTodosCollapsed(): [boolean, (v: boolean) => void] { + const v = useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => localStorage.getItem(TODOS_KEY) === "1", + ); + const set = (c: boolean) => { + localStorage.setItem(TODOS_KEY, c ? "1" : "0"); + listeners.forEach((l) => l()); + }; + return [v, set]; +} diff --git a/web/src/styles.css b/web/src/styles.css index 53cb7f0..e94d611 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -721,6 +721,39 @@ button.session-group:hover { margin: 8px 0; font-size: 13px; } +.todos-head { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + padding: 0; + background: none; + border: none; + color: var(--muted); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.todos-head:hover { + color: var(--fg1); +} +.todos-count { + font-variant-numeric: tabular-nums; +} +.todos-current { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--accent); + font-weight: 600; +} +.todos.collapsed { + padding-block: 6px; +} .todos ul { list-style: none; padding: 0; @@ -3773,3 +3806,104 @@ span.chat-tab > button { .instructions-modal { width: min(640px, calc(100vw - 32px)); } + +/* --- short screens ---------------------------------------------------------- + * Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware + * keyboard) and phones in landscape: height is the scarce dimension, so bars, + * the composer and the new-chat wizard get tighter and the chat keeps the room. */ +@media (max-height: 560px) { + .topbar { + min-height: 40px; + padding: 4px 10px; + padding-top: max(4px, env(safe-area-inset-top)); + gap: 4px; + } + .topbar button.ghost { + padding: 4px 6px; + } + .chat { + padding: 10px 12px 14px; + } + .msg { + margin-bottom: 10px; + } + .composer-wrap { + padding: 6px 10px; + padding-bottom: max(6px, env(safe-area-inset-bottom)); + } + .composer textarea { + min-height: 38px; + max-height: 30vh; + padding: 8px 10px; + } + /* One placeholder line instead of two (the textarea grows with its content). */ + .composer textarea::placeholder { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .composer-bar { + margin-top: 4px; + gap: 4px; + } + .composer-bar select { + max-width: 150px; + padding: 2px 4px; + } + .composer-actions button { + padding: 4px 10px; + } + .todos { + margin: 4px 0; + padding: 6px 10px; + } + .page { + padding: 12px; + } + .wiz-title { + font-size: 19px; + margin: 0 0 8px; + } + .wiz-sub { + display: none; + } + .wiz-panel { + padding: 12px; + } + .wiz-step { + margin-bottom: 16px; + } + .wiz-step h3 { + font-size: 16px; + margin-bottom: 8px; + } + .wiz-cards, + .wiz-cards.five { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + } + .wiz-card { + padding: 8px 10px; + gap: 8px; + min-height: 44px; + font-size: 14px; + } + .wiz-card.active { + padding: 7px 9px; + } + .wiz-card-text { + padding-right: 14px; + } + .wiz-card-desc { + display: none; + } + .wiz-icon { + width: 20px; + height: 20px; + } + .wiz-num { + width: 24px; + height: 24px; + font-size: 12px; + } +} diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 54e1c80..15c9eb0 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"], + de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"], + }, "202611-00010": { en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"], de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],