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"],