Compare commits

..
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 872ebb94dd deploy.sh: image check no longer fails at random (grep -q + pipefail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:44:51 +01:00
Richard Zink b302fa0b17 Release v202611-00012 2026-11-10 08:24:40 +01:00
Richard ZinkandClaude Opus 5.5 bd490efda5 Approval mode of automations in the list; Telegram switch per chat
- Automations: the approval mode is a selection in the list; PATCH /jobs/{id}
  writes permission_mode into the job file's frontmatter (rest of the file
  unchanged, invalid modes rejected). "Allow everything" lets nightly runs work
  without waiting for approvals via Telegram until the timeout.
- Session.telegram_quiet: approvals and questions of a chat (and its subagents)
  are not sent to Telegram; the chat open in Telegram always reports. Toggle in
  the chat's ⋯ menu when a bot is paired. New chats from the web UI and
  automation runs start quiet; chats started from Telegram and existing chats
  keep reporting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:24:34 +01:00
Richard Zink 9339e2db41 Release v202611-00011 2026-11-09 22:16:00 +01:00
Richard ZinkandClaude Opus 5.5 d7f89ee257 Fix stale PWA service worker after updates; collapsible task list; short screens
Installed apps checked their service worker at /sw-<old build>.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 <noreply@anthropic.com>
2026-11-09 22:15:45 +01:00
16 changed files with 384 additions and 12 deletions

No files matched your search

+32
View File
@@ -3,6 +3,38 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00012] – 2026-11-10
### Added
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
automation as a selection; the change is written into the job file and applies from the next
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
wait for an answer via Telegram until the timeout).
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
from the web UI and automation runs start with it switched off (they stay in the web UI);
chats started from Telegram and existing chats keep reporting. The chat currently open in
Telegram always reports.
## [v202611-00011] – 2026-11-09
### 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
+1 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00010" src="https://img.shields.io/badge/version-v202611--00010-d69e2e?style=flat-square">
<img alt="Version v202611-00012" src="https://img.shields.io/badge/version-v202611--00012-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+4
View File
@@ -183,6 +183,10 @@ pub struct Session {
/// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String,
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
/// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool,
}
impl Session {
+3 -1
View File
@@ -73,7 +73,9 @@ case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
+47 -2
View File
@@ -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-<build>.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<impl tower_http::compression::Predicate + Clone> {
@@ -588,6 +620,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
archived: false,
folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -799,6 +833,8 @@ struct UpdateSession {
folder: Option<String>,
/// Instructions for this chat ("" clears them).
instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -847,6 +883,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(i) = body.instructions {
s.instructions = i.trim().to_string();
}
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
})?;
Ok(Json(json!(s)))
}
@@ -1622,10 +1661,16 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)]
struct UpdateJob {
enabled: Option<bool>,
/// Written into the job file's frontmatter.
permission_mode: Option<String>,
}
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
if let Some(m) = b.permission_mode.as_deref() {
crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.permission_mode", &job.path, m);
}
if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?;
}
+51
View File
@@ -165,6 +165,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
archived: false,
folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
@@ -284,6 +286,49 @@ pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
}
}
/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it
/// before the closing `---`); the rest of the file stays as it is.
pub fn set_permission_mode(path: &Path, mode: &str) -> Result<JobSpec> {
if !crate::permission::MODES.contains(&mode) {
bail!("invalid permission_mode '{mode}'");
}
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?;
std::fs::write(path, &updated)?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::write(path, &text);
Err(e)
}
}
}
fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option<String> {
let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" };
let t = &text[bom.len()..];
let rest = t.strip_prefix("---")?;
let end = rest.find("\n---")?;
let (head, tail) = (&rest[..end], &rest[end..]);
let prefix = format!("{key}:");
let mut found = false;
let mut lines: Vec<String> = head
.split('\n')
.map(|l| {
if !found && l.trim_start().starts_with(&prefix) {
found = true;
format!("{key}: {value}")
} else {
l.to_string()
}
})
.collect();
if !found {
lines.push(format!("{key}: {value}"));
}
Some(format!("{bom}---{}{tail}", lines.join("\n")))
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
let dir = project.join(".codecraft/jobs");
@@ -446,6 +491,12 @@ mod tests {
let s = parse(&p).unwrap();
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
assert_eq!(job_cwd(&p, &s), dir);
// Added when missing, replaced when present; comments and the prompt stay.
assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass");
std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap();
assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n");
assert!(set_permission_mode(&p, "yolo").is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1
View File
@@ -705,6 +705,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
archived: false,
folder: None,
instructions: String::new(),
telegram_quiet: false,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
+13
View File
@@ -538,6 +538,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
archived: false,
folder: None,
instructions: String::new(),
// Started from Telegram: keeps reporting there after switching to another chat.
telegram_quiet: false,
})?;
update(app, &u.id, |st| st.session = Some(s.id.clone()));
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
@@ -869,6 +871,17 @@ async fn listen(app: Arc<App>) {
let Some(chat) = st.chat_id else { continue };
let de = german(&app, &owner);
let is_current = st.session.as_deref() == Some(session_id.as_str());
// Chats with "not to Telegram": their approvals and questions stay in the web UI
// (subagents follow their top-level chat; the chat open in Telegram always reports).
if !is_current && matches!(ev, Event::PermissionRequest { .. } | Event::QuestionRequest { .. }) {
let root = app.root_session_id(&session_id);
let quiet = if st.session.as_deref() == Some(root.as_str()) {
false
} else if root == session_id { session.telegram_quiet } else { app.store.get_session(&root).ok().flatten().is_some_and(|r| r.telegram_quiet) };
if quiet {
continue;
}
}
if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await {
tracing::debug!("telegram send: {e:#}");
}
+41 -7
View File
@@ -3,13 +3,13 @@ import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { useFetch, 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";
@@ -152,6 +152,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false);
// The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
@@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<Icon name="edit" /> {t("instructions.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")}
</a>
@@ -503,10 +519,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
<TodoPanel todos={todos} />
)}
{permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} />
@@ -633,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
</div>
);
}
/** 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 (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}
+16 -1
View File
@@ -107,7 +107,22 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · max {j.spec?.timeout_minutes} min
</span>
<span>{t("jobs.permissionMode")}</span>
<span>
<select
value={j.spec?.permission_mode ?? "auto"}
disabled={busy === j.id}
title={t("jobs.permissionModeHint")}
aria-label={t("jobs.permissionMode")}
onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { permission_mode: e.target.value }))}
>
<option value="default">{t("permissionMode.default")}</option>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</span>
</div>
)}
+4
View File
@@ -593,6 +593,10 @@ export const de: Messages = {
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.telegram": "Fragen an Telegram senden",
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
"jobs.permissionMode": "Freigabemodus",
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf (wird in die Datei geschrieben). „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
"instructions.title": "Anweisungen für diesen Chat",
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
+4
View File
@@ -592,6 +592,10 @@ export const en = {
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"chat.telegram": "Send questions to Telegram",
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
"jobs.permissionMode": "Approval mode",
"jobs.permissionModeHint": "Applies from the next run (written to the file). “Allow everything”: the run never asks and never waits for approval.",
"instructions.title": "Instructions for this chat",
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
+18
View File
@@ -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];
}
+139
View File
@@ -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;
@@ -3127,6 +3160,11 @@ button.danger-solid {
.job-grid > span:nth-child(odd) {
color: var(--fg3);
}
.job-grid select {
max-width: 100%;
font-size: 13px;
padding: 2px 6px;
}
.job-prompt {
margin-top: 10px;
}
@@ -3773,3 +3811,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;
}
}
+2
View File
@@ -74,6 +74,8 @@ export interface Session {
folder?: string | null;
/** Instructions for this chat (added to the system prompt). */
instructions?: string;
/** Approvals and questions of this chat are not sent to Telegram. */
telegram_quiet?: boolean;
}
export interface QuestionOption {
+8
View File
@@ -3,6 +3,14 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00012": {
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
},
"202611-00011": {
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"],