Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 |
No files matched your search
@@ -3,6 +3,38 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
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
|
## [v202611-00010] – 2026-11-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="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">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ pub struct Session {
|
|||||||
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub instructions: String,
|
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 {
|
impl Session {
|
||||||
|
|||||||
+47
-2
@@ -172,8 +172,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.find(|p| p.join("index.html").exists())
|
.find(|p| p.join("index.html").exists())
|
||||||
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
.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(missing_static_file(web_dir.clone()));
|
||||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.nest("/api", api)
|
.nest("/api", api)
|
||||||
@@ -186,6 +185,39 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.with_state(app)
|
.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
|
/// 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).
|
/// predicate), and so are binary downloads (already compressed or large).
|
||||||
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
|
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,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
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)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -799,6 +833,8 @@ struct UpdateSession {
|
|||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
/// Instructions for this chat ("" clears them).
|
/// Instructions for this chat ("" clears them).
|
||||||
instructions: Option<String>,
|
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 {
|
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 {
|
if let Some(i) = body.instructions {
|
||||||
s.instructions = i.trim().to_string();
|
s.instructions = i.trim().to_string();
|
||||||
}
|
}
|
||||||
|
if let Some(q) = body.telegram_quiet {
|
||||||
|
s.telegram_quiet = q;
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
@@ -1622,10 +1661,16 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UpdateJob {
|
struct UpdateJob {
|
||||||
enabled: Option<bool>,
|
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 {
|
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)?;
|
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 {
|
if let Some(e) = b.enabled {
|
||||||
app.store.job_set_enabled(&job.id, e)?;
|
app.store.job_set_enabled(&job.id, e)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
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)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
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/`.
|
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
|
||||||
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
|
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
|
||||||
let dir = project.join(".codecraft/jobs");
|
let dir = project.join(".codecraft/jobs");
|
||||||
@@ -446,6 +491,12 @@ mod tests {
|
|||||||
let s = parse(&p).unwrap();
|
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!((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);
|
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();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
telegram_quiet: false,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
|
|||||||
@@ -538,6 +538,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
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()));
|
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?;
|
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 Some(chat) = st.chat_id else { continue };
|
||||||
let de = german(&app, &owner);
|
let de = german(&app, &owner);
|
||||||
let is_current = st.session.as_deref() == Some(session_id.as_str());
|
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 {
|
if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await {
|
||||||
tracing::debug!("telegram send: {e:#}");
|
tracing::debug!("telegram send: {e:#}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { Icon } from "./Icon";
|
|||||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { del, get, patch, post } from "../api";
|
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 { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
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 { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode } from "../prefs";
|
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import { RenameDialog } from "./RenameDialog";
|
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 isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||||
const [sharing, setSharing] = useState(false);
|
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 [editingInstructions, setEditingInstructions] = useState(false);
|
||||||
const models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
@@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</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>
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
<Icon name="download" /> {t("chat.exportMd")}
|
<Icon name="download" /> {t("chat.exportMd")}
|
||||||
</a>
|
</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} />
|
<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) && (
|
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||||
<div className="todos">
|
<TodoPanel todos={todos} />
|
||||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
|
||||||
<TodoList todos={todos} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{permissions.map((p) => (
|
{permissions.map((p) => (
|
||||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||||
@@ -633,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,7 +107,22 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>{t("jobs.mode")}</span>
|
<span>{t("jobs.mode")}</span>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -593,6 +593,10 @@ export const de: Messages = {
|
|||||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||||
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
"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.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.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.",
|
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
||||||
|
|||||||
@@ -592,6 +592,10 @@ export const en = {
|
|||||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||||
"chat.compactingManual": "Compacting the conversation",
|
"chat.compactingManual": "Compacting the conversation",
|
||||||
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
"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.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.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.",
|
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
||||||
|
|||||||
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
|
|||||||
getReasoningMode,
|
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];
|
||||||
|
}
|
||||||
@@ -721,6 +721,39 @@ button.session-group:hover {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
font-size: 13px;
|
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 {
|
.todos ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3127,6 +3160,11 @@ button.danger-solid {
|
|||||||
.job-grid > span:nth-child(odd) {
|
.job-grid > span:nth-child(odd) {
|
||||||
color: var(--fg3);
|
color: var(--fg3);
|
||||||
}
|
}
|
||||||
|
.job-grid select {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
.job-prompt {
|
.job-prompt {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
@@ -3773,3 +3811,104 @@ span.chat-tab > button {
|
|||||||
.instructions-modal {
|
.instructions-modal {
|
||||||
width: min(640px, calc(100vw - 32px));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,8 @@ export interface Session {
|
|||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
/** Instructions for this chat (added to the system prompt). */
|
/** Instructions for this chat (added to the system prompt). */
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
/** Approvals and questions of this chat are not sent to Telegram. */
|
||||||
|
telegram_quiet?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
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": {
|
"202611-00010": {
|
||||||
en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"],
|
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"],
|
de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],
|
||||||
|
|||||||
Reference in new issue
Block a user