Compare commits

...
Author SHA1 Message Date
Richard Zink b5e2ef146c Release v202611-00040 2026-11-15 15:09:13 +01:00
Richard ZinkandClaude Sonnet 5.5 a2b9528e0b Fix uploaded images reaching Claude Code empty
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 15:09:11 +01:00
Richard Zink 81d9df4e6e Release v202611-00039 2026-11-15 14:59:12 +01:00
Richard ZinkandClaude Sonnet 5.5 ead75e78e0 Neutral chat background for colour tags; answer Claude Code's AskUserQuestion with the question card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:59:10 +01:00
Richard Zink 77f9d4b0e0 Release v202611-00038 2026-11-15 14:37:31 +01:00
Richard ZinkandClaude Sonnet 5.5 103132a359 Sidebar chat menu: add Delete, remove colour and icon pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:37:29 +01:00
Richard Zink 5d5c0f8124 Release v202611-00037 2026-11-15 13:53:13 +01:00
Richard ZinkandClaude Sonnet 5.5 f77b101847 Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:53:10 +01:00
Richard Zink 12a3198e77 Release v202611-00036 2026-11-15 13:04:03 +01:00
Richard ZinkandClaude Sonnet 5.5 4e6f74d477 Offer all CodeCraft tools to the Claude Code CLI over an MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:04:00 +01:00
Richard Zink 658148ada7 Release v202611-00035 2026-11-15 12:33:26 +01:00
Richard ZinkandClaude Sonnet 5.5 b6e4500233 Fix SSH key creation as a uid without passwd entry (ssh-keygen, ssh-keyscan, ssh-add wrappers)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:33:24 +01:00
Richard Zink 126c7719b9 Release v202611-00034 2026-11-15 12:15:04 +01:00
Richard ZinkandClaude Sonnet 5.5 fccaee4b94 Fix ssh/scp/sftp for containers running as a uid without passwd entry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:15:02 +01:00
Richard Zink d58b23d35f Release v202611-00033 2026-11-15 11:47:34 +01:00
Richard ZinkandClaude Sonnet 5.5 6e8e739906 SSH keys in account settings; wrappers for agents, terminal and git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:47:31 +01:00
Richard Zink 247ddcf14a Release v202611-00032 2026-11-15 10:52:10 +01:00
Richard ZinkandClaude Sonnet 5.5 f0cd404f59 Tell agents about stored git credentials; give Claude Code the user's git config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 10:52:07 +01:00
Richard Zink ec95f9b8ad Release v202611-00031 2026-11-15 09:54:04 +01:00
Richard ZinkandClaude Sonnet 5.5 99ba3835f3 Show images read by agents; keep tool-result images with Claude Code
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:54:00 +01:00
Richard Zink 92a99c6a65 Release v202611-00030 2026-11-15 09:24:58 +01:00
Richard ZinkandClaude Sonnet 5.5 4c7d67cabf Add /clear command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:24:56 +01:00
Richard Zink 4423148552 Release v202611-00029 2026-11-15 08:20:53 +01:00
Richard ZinkandClaude Sonnet 5.5 388ca96419 Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 08:20:52 +01:00
Richard Zink c08c6c7436 Release v202611-00028 2026-11-14 23:53:20 +01:00
Richard ZinkandClaude Sonnet 5.5 1b3e3af4b7 More chat icons; message menu inside the bubble
- 18 more icons (gamepad, computer, food, ...) with a test that keeps the
  server whitelist and the client picker identical.
- The ⋯ menu of a message moves into the bubble foot (next to the time); its
  popup opens upwards inside the bubble and is limited to the bubble's width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:53:14 +01:00
Richard Zink 44dd6978d4 Release v202611-00027 2026-11-14 23:32:10 +01:00
Richard ZinkandClaude Sonnet 5.5 8a90f83db5 Changelog: bubbles, chat icons, colour tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:32:03 +01:00
Richard ZinkandClaude Sonnet 5.5 ce0d8afe69 Chat bubbles, icons for chats, more vivid and distinct colour tags
- Bubbles: own messages right-aligned in their own colour, agent answers left.
- Chat icons (22, validated on the server), shown in list, top bar, project page.
- Colour tags: eight fixed hues (new: pink) with theme-matched lightness and the
  highest chroma sRGB allows; stronger background tint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:31:52 +01:00
Richard Zink f34a0c7a73 Release v202611-00026 2026-11-14 22:31:47 +01:00
Richard ZinkandClaude Sonnet 5.5 62fe5bf467 Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 22:30:12 +01:00
72 changed files with 6942 additions and 160 deletions

No files matched your search

+161
View File
@@ -3,6 +3,167 @@
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-00040] – 2026-11-15
### Fixed
- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the
files it was pointed to were empty. Images without data are no longer written as empty files.
## [v202611-00039] – 2026-11-15
### Changed
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
### Fixed
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
## [v202611-00038] – 2026-11-15
### Changed
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
## [v202611-00037] – 2026-11-15
### Added
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
not shared with any other chat, no project folder), you can delete it with the chat – with the
number of files, size and a warning for git repositories. The server checks again before deleting.
## [v202611-00036] – 2026-11-15
### Added
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
offered twice. Calls go through the usual approval cards and rules.
## [v202611-00035] – 2026-11-15
### Fixed
- Creating an SSH key failed with "No user exists for uid 99": `ssh-keygen`, `ssh-keyscan` and
`ssh-add` get the same passwd workaround as `ssh`.
## [v202611-00034] – 2026-11-15
### Fixed
- `ssh`, `scp` and `sftp` failed with "No user exists for uid 99" because the container runs as a uid
without passwd entry; the image now wraps them with nss_wrapper (also used by the key wrappers).
## [v202611-00033] – 2026-11-15
### Added
- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the
git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are
trusted on first contact; the public key can be copied for the server's `authorized_keys`.
## [v202611-00032] – 2026-11-15
### Fixed
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
environment for them; the system prompt explains `git push` just works and how to read the token
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
## [v202611-00031] – 2026-11-15
### Fixed
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
## [v202611-00030] – 2026-11-15
### Added
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
the cut. Also starts a fresh session with Claude Code.
## [v202611-00029] – 2026-11-15
### Added
- **Statistics**: sections and models fold; long history with a year filter (monthly bars for long
ranges); export of the shown figures as XLSX (all tables) or CSV (one table).
- **Prompt snippets**: own text blocks, inserted with `/name` in the message field (Settings → Account).
- **Read-only links**: share a chat's text with people without an account (valid 1–90 days, revocable).
- **Git panel**: the small model suggests a commit message for the staged changes.
- **Weekly review** as a push notification (Mondays 08:00: chats, checks, costs; opt-in).
- **Speech-to-text on the server** (`server.transcribe_model`) for browsers without dictation (Firefox).
- **Offline**: the chat list and the last opened chats stay readable without a connection.
- **Planning model** setting: plan mode always uses it, other modes the chat's model.
- **Automations** send their report as a push notification when a run ends (switchable).
### Fixed
- fal.ai: the provider check no longer reports "API key rejected" when only the model listing is not offered.
## [v202611-00028] – 2026-11-14
### Added
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
star, globe, calendar, car, plane, bulb, money, palette).
### Changed
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
## [v202611-00027] – 2026-11-14
### Added
- **Chat bubbles:** your messages on the right in their own colour (the chat's colour tag if it
has one), the agent's answers on the left; tool-only steps and system notes stay without bubble.
- **Icons for chats:** pick one of 22 icons in the ⋯ menu (chat and session list); it shows in the
list, the top bar and the project page, in the chat's colour, and is part of the project default.
### Changed
- **Colour tags are more vivid and more distinct:** eight fixed hues (new: pink), drawn with a
lightness that fits the theme (light/dark) and the highest chroma sRGB allows, so no two tags
look alike; backgrounds are tinted more strongly. Browsers without relative colours keep the
theme's own colours.
## [v202611-00026] – 2026-11-14
### Added
- **Claude Code as an engine:** chats with a "Claude Code" model (Opus, Sonnet, Haiku, default) are
run by the `claude` CLI. Its events become normal chat messages; its tool approvals go through
CodeCraft's permission system (cards, push buttons, "allow for this project", diffs, sandbox).
Sign in from the web UI (Settings → Account → Claude Code: open the sign-in URL, paste the code),
see the CLI version and install updates (admins; installed into the persistent home). The Docker
image now contains the CLI.
- **Small models (8k context) for side tasks:** `small_model` jobs cut their input to the model's
context window. New with it: a one-line status per chat in the session list, "What does this
do?" on approvals, and "Plan as automation" (drafts job instructions from a chat).
- **Mobile:** the top bar no longer pushes the page sideways (extra actions moved into the ⋯ menu);
an image viewer for all images (zoom, swipe between images, mark up and attach to the message,
use in the message); jump to the previous own message; message menu (copy, quote, fork from
here, edit and resend); date and time under every message.
- **Approvals:** push notifications can allow or reject right away; "always allow in this project"
is remembered per folder (never for dangerous commands) and can be revoked in the ⋯ menu.
- **Changes per turn:** files changed by a turn with diffs and "roll back to before this turn".
- **Check after each turn:** a command per project (e.g. `cargo test`) runs after turns that
changed files; green/red badge with output and "let the agent fix it".
- **Colour tags for chats:** tinted chat background and a badge in the list, drawn in the colours
of the active theme.
- **Project defaults, project page, offline queue:** new chats in a folder inherit model, mode,
colour, instructions and folder; a timeline of a project's chats (sidebar → project → ⋯);
messages written without a connection are sent when it is back.
## [v202611-00025] – 2026-11-12 ## [v202611-00025] – 2026-11-12
### Fixed ### Fixed
+9 -2
View File
@@ -85,11 +85,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \ openscad xvfb xauth \
openssh-client rsync mc \ openssh-client libnss-wrapper rsync mc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be # Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …). # installed by agents: rustup component add rust-analyzer, go install …).
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force RUN npm install -g typescript@5 typescript-language-server pyright @anthropic-ai/claude-code && npm cache clean --force
COPY --from=server /codecraft /usr/local/bin/codecraft COPY --from=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli COPY --from=cli /out /app/cli
@@ -98,6 +98,13 @@ COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra COPY server/ghidra /app/ghidra
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer). # git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
# ssh, scp, sftp and the ssh-* tools (the server creates keys with ssh-keygen) also work when the container runs as a uid without passwd entry.
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh
COPY --chmod=755 docker/ssh-nss /usr/local/bin/scp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/sftp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keygen
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keyscan
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-add
COPY server/resources /app/resources COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space # The agent's home lives on the data volume: tools it installs in user space
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds. # (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00025" src="https://img.shields.io/badge/version-v202611--00025-d69e2e?style=flat-square"> <img alt="Version v202611-00040" src="https://img.shields.io/badge/version-v202611--00040-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">
+43 -1
View File
@@ -187,8 +187,27 @@ pub struct Session {
/// unless it is the chat currently open in Telegram. /// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool, pub telegram_quiet: bool,
/// One-line "where things stand", written by the small model after a turn (session list).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
/// Icon of the chat (a name from `SESSION_ICONS`), shown in the list and the top bar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
} }
/// Names of the colour tags: the client draws them with the colours of the active theme.
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
/// Icons a chat can get (names of the client's icon set).
pub const SESSION_ICONS: &[&str] = &[
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
];
impl Session { impl Session {
pub fn clean_room(&self, default: bool) -> bool { pub fn clean_room(&self, default: bool) -> bool {
self.clean_room.unwrap_or(default) self.clean_room.unwrap_or(default)
@@ -243,6 +262,8 @@ pub enum Event {
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by /// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
/// a nearly full context (tokens of limit). /// a nearly full context (tokens of limit).
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> }, Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
/// The project's check command ran after a turn: status "running" | "passed" | "failed" | "timeout".
CheckResult { session_id: String, message_id: String, status: String, exit_code: Option<i32>, duration_ms: u64, output: String },
} }
impl Event { impl Event {
@@ -265,7 +286,8 @@ impl Event {
| Waiting { session_id, .. } | Waiting { session_id, .. }
| QueueUpdated { session_id, .. } | QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } | StepStarted { session_id, .. }
| Compaction { session_id, .. } => Some(session_id), | Compaction { session_id, .. }
| CheckResult { session_id, .. } => Some(session_id),
McpStatus { .. } => None, McpStatus { .. } => None,
} }
} }
@@ -278,3 +300,23 @@ pub fn new_id() -> String {
pub fn now_ms() -> i64 { pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis() chrono::Utc::now().timestamp_millis()
} }
#[cfg(test)]
mod icon_tests {
use super::*;
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
#[test]
fn session_icons_match_the_client() {
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
client.sort();
server.sort();
assert_eq!(client, server);
let mut dedup = server.clone();
dedup.dedup();
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
}
}
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# ssh, scp and sftp for containers that run as an arbitrary uid (docker-compose `user:`):
# OpenSSH refuses to start without a passwd entry for the uid ("No user exists for uid N").
# Without an entry, nss_wrapper provides one for this process; otherwise nothing changes.
if ! getent passwd "$(id -u)" >/dev/null 2>&1; then
d=${TMPDIR:-/tmp}/codecraft-nss-$(id -u)
mkdir -p "$d" 2>/dev/null
printf 'codecraft:x:%s:%s:CodeCraft:%s:/bin/sh\n' "$(id -u)" "$(id -g)" "${HOME:-/}" > "$d/passwd"
printf 'codecraft:x:%s:\n' "$(id -g)" > "$d/group"
export NSS_WRAPPER_PASSWD="$d/passwd" NSS_WRAPPER_GROUP="$d/group"
export LD_PRELOAD="libnss_wrapper.so${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "/usr/bin/$(basename "$0")" "$@"
+611 -16
View File
@@ -7,7 +7,7 @@ use crate::config::{self, ConfigPatch};
use crate::prompts; use crate::prompts;
use crate::runner; use crate::runner;
use crate::store::User; use crate::store::User;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; use crate::types::{Event, ImageData, Part, Role, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
.route("/agents/tools", get(agent_tools)) .route("/agents/tools", get(agent_tools))
.route("/agents/{name}", delete(delete_agent)) .route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands)) .route("/commands", get(commands))
.route("/transcribe", post(transcribe))
.route("/sessions", get(list_sessions).post(create_session)) .route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions)) .route("/sessions/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session)) .route("/sessions/{id}/fork", post(fork_session))
@@ -85,19 +86,37 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/search", get(search_in_session)) .route("/sessions/{id}/search", get(search_in_session))
.route("/sessions/{id}/media", get(session_media)) .route("/sessions/{id}/media", get(session_media))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/folder", get(session_folder))
.route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind)) .route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/job-draft", post(job_draft))
.route("/projects/overview", get(project_overview))
.route("/sessions/{id}/project-defaults", get(get_project_defaults).put(put_project_defaults).delete(delete_project_defaults))
.route("/sessions/{id}/check", get(get_check).put(put_check))
.route("/sessions/{id}/checks", get(list_checks))
.route("/sessions/{id}/check/run", post(run_check))
.route("/sessions/{id}/changes", get(changed_files)) .route("/sessions/{id}/changes", get(changed_files))
.route("/sessions/{id}/shells", get(list_shells)) .route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell)) .route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
.route("/sessions/{id}/stats", get(session_stats)) .route("/sessions/{id}/stats", get(session_stats))
.route("/sessions/{id}/links", get(list_links).post(create_link))
.route("/sessions/{id}/links/{token}", delete(delete_link))
.route("/public/chat/{token}", get(public_chat))
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue))
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item)) .route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
.route("/permissions/{id}", post(reply_permission)) .route("/permissions/{id}", post(reply_permission))
.route("/permissions/{id}/explain", post(explain_permission))
.route("/claude/status", get(claude_status))
.route("/claude/login", post(claude_login_start).delete(claude_login_cancel))
.route("/claude/login/code", post(claude_login_code))
.route("/claude/logout", post(claude_logout))
.route("/claude/update", post(claude_update))
.route("/sessions/{id}/approvals", get(project_approvals).delete(delete_project_approval))
.route("/questions/{id}", post(answer_question)) .route("/questions/{id}", post(answer_question))
.route("/pending", get(pending)) .route("/pending", get(pending))
.route("/mcp", get(mcp_status)) .route("/mcp", get(mcp_status))
@@ -120,6 +139,7 @@ pub fn router(app: AppState) -> Router {
.route("/git", post(crate::git::handle)) .route("/git", post(crate::git::handle))
.route("/fs/dirs", get(list_dirs)) .route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats)) .route("/stats", get(stats))
.route("/stats/export", get(stats_export))
.route("/budget", get(budget)) .route("/budget", get(budget))
.route("/audit", get(audit_log)) .route("/audit", get(audit_log))
.route("/backup", get(backup)) .route("/backup", get(backup))
@@ -139,6 +159,9 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image)) .route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file)) .route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings)) .route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
.route("/account/ssh/{id}", delete(delete_ssh_key))
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar)) .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
@@ -308,7 +331,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }; let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
if !user.is_admin() { if !user.is_admin() {
// Users only need defaults, not the configuration. // Users only need defaults, not the configuration.
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency}))); return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency, "transcribe": !cfg.server.transcribe_model.is_empty()})));
} }
Ok(Json(json!({ Ok(Json(json!({
"config": config::redacted_json(&cfg), "config": config::redacted_json(&cfg),
@@ -316,6 +339,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
"default_model": default_model, "default_model": default_model,
"small_model": cfg.server.small_model, "small_model": cfg.server.small_model,
"currency": cfg.server.currency, "currency": cfg.server.currency,
"transcribe": !cfg.server.transcribe_model.is_empty(),
}))) })))
} }
@@ -595,13 +619,17 @@ fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf,
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult { async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, &user, body.cwd)?; let cwd = validate_cwd(&app, &user, body.cwd)?;
let cfg = app.config(); let cfg = app.config();
// What the project's chats start with (⋯ menu "use as project default").
let defaults = app.store.project_defaults(&cwd.display().to_string()).ok().flatten().unwrap_or(Value::Null);
let dstr = |k: &str| defaults[k].as_str().filter(|v| !v.is_empty()).map(String::from);
let model = body let model = body
.model .model
.filter(|m| !m.is_empty()) .filter(|m| !m.is_empty())
.or_else(|| dstr("model").filter(|m| app.registry().resolve(m).is_ok()))
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())) .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model()) .or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?; .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
let mode = body.permission_mode.unwrap_or_else(|| "auto".into()); let mode = body.permission_mode.or_else(|| dstr("permission_mode")).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&mode.as_str()) { if !crate::permission::MODES.contains(&mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
} }
@@ -624,10 +652,13 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
worktree: None, worktree: None,
pinned: false, pinned: false,
archived: false, archived: false,
folder: None, folder: dstr("folder"),
instructions: String::new(), instructions: dstr("instructions").unwrap_or_default(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
icon: dstr("icon").filter(|c| codecraft_core::types::SESSION_ICONS.contains(&c.as_str())),
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -921,6 +952,10 @@ struct UpdateSession {
instructions: Option<String>, instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram. /// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>, telegram_quiet: Option<bool>,
/// Colour tag ("" removes it).
color: Option<String>,
/// Icon ("" removes it).
icon: Option<String>,
} }
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 {
@@ -933,6 +968,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(m) = &body.model { if let Some(m) = &body.model {
app.registry().resolve(m)?; app.registry().resolve(m)?;
} }
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
}
if body.icon.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_ICONS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid icon".into()));
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) { if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
} }
@@ -972,6 +1013,12 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(q) = body.telegram_quiet { if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q; s.telegram_quiet = q;
} }
if let Some(c) = body.color {
s.color = (!c.is_empty()).then_some(c);
}
if let Some(i) = body.icon {
s.icon = (!i.is_empty()).then_some(i);
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -1010,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { /// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
writable(&app, &user, &id)?; /// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
/// Returns the folder and the titles of chats that prevent it.
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
return (None, vec![]);
}
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
return (None, vec![]);
}
let mut blockers = Vec::new();
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
if other.starts_with(&dir) || dir.starts_with(&other) {
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
}
}
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
}
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let (dir, blockers) = own_folder(&app, &session);
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
let mut stack = vec![dir.clone()];
let mut truncated = false;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
// Git's own files are not "the chat's files".
if e.file_name() == ".git" {
continue;
}
seen += 1;
if seen > 50_000 {
truncated = true;
stack.clear();
break;
}
let Ok(m) = e.metadata() else { continue };
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
stack.push(e.path());
} else if m.is_file() {
files += 1;
bytes += m.len();
}
}
}
(files, bytes, dir.join(".git").exists(), truncated)
}
})
.await
.unwrap_or((0, 0, false, false));
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
}
#[derive(Deserialize)]
struct DeleteQ {
/// Also delete the chat's own folder (only if it really is one – checked again here).
#[serde(default)]
delete_folder: bool,
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
let session = writable(&app, &user, &id)?;
// Decide about the folder while the chat and all the others still exist.
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
if q.delete_folder && folder.is_none() {
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
}
// Notify before deleting: visibility is checked against the stored session. // Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() }); app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id); runner::abort(&app, &id);
@@ -1025,7 +1147,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}"); tracing::warn!("image cleanup: {e:#}");
} }
}); });
Ok(Json(json!({"ok": true}))) let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
folder_deleted = true;
app.audit(Some(&user), "session.delete_folder", &shown, "");
}
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
}
}
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes, /// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
@@ -1085,6 +1220,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
tokio::spawn(do_compact(app.clone(), id.clone())); tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(json!({"ok": true, "command": "compact"})); return Ok(json!({"ok": true, "command": "compact"}));
} }
if text == "/clear" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
return Ok(json!({"ok": true, "command": "clear"}));
}
let cwd = PathBuf::from(&session.cwd); let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd); let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone(); let mut prompt_text = text.clone();
@@ -1231,13 +1373,47 @@ async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)] #[derive(Deserialize)]
struct ChangesQuery { struct ChangesQuery {
since_message: String, since_message: String,
/// Include a unified diff per file.
#[serde(default)]
diff: bool,
/// Only changes before this message (one turn instead of everything since).
until_message: Option<String>,
} }
/// Files the agent changed since a message (for the rewind dialog). /// Files the agent changed since a message (for the rewind dialog).
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult { async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
readable(&app, &user, &id)?; readable(&app, &user, &id)?;
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX); let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect(); let until = match &q.until_message {
Some(m) => app.store.message_created_at(&id, m)?.unwrap_or(i64::MAX),
None => i64::MAX,
};
let files: Vec<Value> = app
.store
.checkpoints_between(&id, since, until)?
.into_iter()
.map(|(p, c)| {
let mut v = json!({"path": p, "created": c.is_none()});
if q.diff {
// Checkpoint (state before the agent's first change) against the file now.
let old = c.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
let new = std::fs::read_to_string(&p);
v["deleted"] = json!(new.is_err());
let new = new.unwrap_or_default();
let mut diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&p, &p).to_string();
if diff.len() > 60_000 {
let mut end = 60_000;
while !diff.is_char_boundary(end) {
end -= 1;
}
diff.truncate(end);
v["truncated"] = json!(true);
}
v["diff"] = json!(diff);
}
v
})
.collect();
Ok(Json(json!(files))) Ok(Json(json!(files)))
} }
@@ -1248,6 +1424,117 @@ async fn session_stats(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(v)) Ok(Json(v))
} }
// --- speech to text -----------------------------------------------------------
#[derive(Deserialize)]
struct TranscribeQ {
lang: Option<String>,
}
/// Dictation for browsers without speech recognition: the recorded audio (request body) goes
/// to the configured OpenAI-compatible transcription endpoint.
async fn transcribe(State(app): State<AppState>, Query(q): Query<TranscribeQ>, headers: axum::http::HeaderMap, body: axum::body::Bytes) -> ApiResult {
let cfg = app.config();
let spec = cfg.server.transcribe_model.clone();
let (prov, model) = spec.split_once('/').ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, "speech-to-text is not configured (server.transcribe_model)".into()))?;
let p = cfg.providers.get(prov).filter(|p| p.enabled).ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, format!("transcription provider '{prov}' is not available")))?;
if body.is_empty() || body.len() > 25 * 1024 * 1024 {
return Err(ApiError(StatusCode::BAD_REQUEST, "audio missing or larger than 25 MB".into()));
}
let mime = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("audio/webm").split(';').next().unwrap_or("audio/webm").to_string();
let ext = if mime.contains("mp4") || mime.contains("aac") { "m4a" } else if mime.contains("ogg") { "ogg" } else if mime.contains("wav") { "wav" } else { "webm" };
let part = reqwest::multipart::Part::bytes(body.to_vec()).file_name(format!("speech.{ext}")).mime_str(&mime).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e.to_string()))?;
let mut form = reqwest::multipart::Form::new().part("file", part).text("model", model.to_string());
if let Some(l) = q.lang.as_deref().filter(|l| l.len() == 2 && l.chars().all(|c| c.is_ascii_lowercase())) {
form = form.text("language", l.to_string());
}
let url = format!("{}/audio/transcriptions", p.base_url.trim_end_matches('/'));
let mut req = reqwest::Client::new().post(&url).multipart(form).timeout(std::time::Duration::from_secs(120));
if !p.api_key.is_empty() {
req = match &p.auth_header {
Some(h) => req.header(h.as_str(), format!("{}{}", p.auth_prefix.clone().unwrap_or_default(), p.api_key)),
None => req.bearer_auth(&p.api_key),
};
}
for (k, v) in &p.headers {
req = req.header(k.as_str(), v.as_str());
}
let resp = req.send().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("transcription request failed: {e}")))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ApiError(StatusCode::BAD_GATEWAY, format!("transcription failed ({status}): {}", text.chars().take(300).collect::<String>())));
}
let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"text": text.trim()}));
Ok(Json(json!({"text": v["text"].as_str().unwrap_or("").trim()})))
}
// --- read-only links ----------------------------------------------------------
#[derive(Deserialize)]
struct LinkBody {
/// Validity in days (1–90, default 7).
days: Option<i64>,
}
fn link_json(base: &str, token: &str, created: i64, expires: i64) -> Value {
json!({"token": token, "url": format!("{base}/#/shared/{token}"), "created_at": created, "expires_at": expires})
}
async fn list_links(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
let base = public_base(&app, &headers);
let now = now_ms();
let links: Vec<Value> = app.store.share_links(&id)?.into_iter().filter(|(_, _, exp)| *exp > now).map(|(t, c, e)| link_json(&base, &t, c, e)).collect();
Ok(Json(json!(links)))
}
async fn create_link(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, body: Option<Json<LinkBody>>) -> ApiResult {
writable(&app, &user, &id)?;
let days = body.and_then(|b| b.0.days).unwrap_or(7).clamp(1, 90);
let now = now_ms();
let token = format!("{}{}", new_id(), new_id()).replace('-', "");
let expires = now + days * 86_400_000;
app.store.purge_share_links(now)?;
app.store.add_share_link(&token, &id, &user.id, now, expires)?;
app.audit(Some(&user), "session.link", &format!("{id}: {days} day(s)"), "");
Ok(Json(link_json(&public_base(&app, &headers), &token, now, expires)))
}
async fn delete_link(Extension(user): Extension<User>, State(app): State<AppState>, Path((id, token)): Path<(String, String)>) -> ApiResult {
writable(&app, &user, &id)?;
app.store.delete_share_link(&id, &token)?;
app.audit(Some(&user), "session.link.revoke", &id, "");
Ok(Json(json!({"ok": true})))
}
/// A chat for people without an account: only the conversation text (no tool output,
/// reasoning or attachments), readable as long as the link is valid.
async fn public_chat(State(app): State<AppState>, Path(token): Path<String>) -> ApiResult {
let gone = || ApiError(StatusCode::NOT_FOUND, "this link is invalid or has expired".into());
let id = app.store.share_link_session(&token, now_ms())?.ok_or_else(gone)?;
let session = app.session(&id).map_err(|_| gone())?;
let messages: Vec<Value> = app
.store
.messages(&id)?
.into_iter()
.filter(|m| matches!(m.role, Role::User | Role::Assistant))
.filter_map(|m| {
let text = m
.parts
.iter()
.filter_map(|p| match p {
Part::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
(!text.trim().is_empty()).then(|| json!({"id": m.id, "role": m.role, "text": text, "created_at": m.created_at}))
})
.collect();
Ok(Json(json!({"title": session.title, "updated_at": session.updated_at, "messages": messages})))
}
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?; writable(&app, &user, &id)?;
Ok(Json(json!(app.store.shares(&id)?))) Ok(Json(json!(app.store.shares(&id)?)))
@@ -1291,6 +1578,201 @@ async fn reply_permission(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
/// Instructions for an automation drafted from this chat (small model); the client creates the job.
async fn job_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent})))
}
// --- Claude Code: sign-in from the web UI ---------------------------------------------------
#[derive(Deserialize, Default)]
struct ClaudeStatusQuery {
#[serde(default)]
refresh: bool,
}
async fn claude_status(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<ClaudeStatusQuery>) -> Json<Value> {
if q.refresh {
crate::claude_code::refresh_latest().await;
}
Json(crate::claude_code::status(&app, &user.id).await)
}
#[derive(Deserialize, Default)]
struct ClaudeLoginBody {
/// Anthropic Console (API billing) instead of the Claude subscription.
#[serde(default)]
console: bool,
}
async fn claude_login_start(Extension(user): Extension<User>, State(app): State<AppState>, body: Option<Json<ClaudeLoginBody>>) -> ApiResult {
let console = body.map(|b| b.0.console).unwrap_or(false);
let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login_start", "", "");
Ok(Json(json!({"url": url})))
}
#[derive(Deserialize)]
struct ClaudeCodeBody {
code: String,
}
async fn claude_login_code(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ClaudeCodeBody>) -> ApiResult {
crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login", "", "");
// New models may have appeared (the CLI is signed in now): clients reload them.
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
async fn claude_login_cancel(Extension(user): Extension<User>) -> Json<Value> {
crate::claude_code::login_cancel(&user.id);
Json(json!({"ok": true}))
}
/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts).
async fn claude_update(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.update", "", "");
let mut v = crate::claude_code::status(&app, &user.id).await;
v["output"] = json!(output);
Ok(Json(v))
}
async fn claude_logout(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.logout", "", "");
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
// --- project page ---------------------------------------------------------------------------------
#[derive(Deserialize)]
struct OverviewQuery {
cwd: String,
}
/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings.
async fn project_overview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<OverviewQuery>) -> ApiResult {
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let sessions: Vec<Session> = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect();
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let check = app.store.project_check(&q.cwd)?;
Ok(Json(json!({
"sessions": app.store.project_overview(&ids)?,
"check": check.map(|c| json!({"command": c.0, "enabled": c.1})),
"defaults": app.store.project_defaults(&q.cwd)?,
"currency": app.config().server.currency,
})))
}
// --- defaults of new chats per project ---------------------------------------------------------
async fn get_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null)))
}
/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder.
async fn put_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let data = json!({
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
"color": s.color, "icon": s.icon, "instructions": s.instructions, "folder": s.folder,
});
app.store.set_project_defaults(&s.cwd, Some(&data))?;
Ok(Json(data))
}
async fn delete_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.set_project_defaults(&s.cwd, None)?;
Ok(Json(json!({"ok": true})))
}
// --- check command per project ------------------------------------------------------------------
async fn get_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
let c = app.store.project_check(&s.cwd)?;
Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)})))
}
#[derive(Deserialize)]
struct CheckBody {
command: String,
#[serde(default = "yes")]
enabled: bool,
}
fn yes() -> bool {
true
}
async fn put_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<CheckBody>) -> ApiResult {
let s = writable(&app, &user, &id)?;
if b.command.len() > 2000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into()));
}
app.store.set_project_check(&s.cwd, &b.command, b.enabled)?;
app.audit(Some(&user), "project.check", &s.cwd, &b.command);
Ok(Json(json!({"ok": true})))
}
async fn list_checks(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
Ok(Json(json!(app.store.turn_checks(&id)?)))
}
/// Run the check now for the latest answer.
async fn run_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?;
let app2 = app.clone();
tokio::spawn(async move {
let _ = crate::checks::run(&app2, &s, &last.id).await;
});
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize, Default)]
struct ExplainBody {
lang: Option<String>,
}
/// Plain-words explanation of a pending approval (small model).
async fn explain_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ExplainBody>>) -> ApiResult {
check_pending(&app, &user, &id)?;
let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?;
let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) };
let session = app.session(&session_id)?;
let lang = body.and_then(|b| b.0.lang).unwrap_or_default();
let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang)
.await
.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"text": text})))
}
/// What is allowed "for this project" (remembered across chats).
async fn project_approvals(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(json!(app.store.project_approvals(&s.cwd)?)))
}
#[derive(Deserialize)]
struct ApprovalKey {
key: String,
}
async fn delete_project_approval(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<ApprovalKey>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.delete_project_approval(&s.cwd, &body.key)?;
app.audit(Some(&user), "permission.project_revoked", body.key, "");
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct QuestionBody { struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed /// question text → answer (string or array of strings); null = dismissed
@@ -1703,6 +2185,45 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
Ok(Json(v)) Ok(Json(v))
} }
#[derive(Deserialize)]
struct StatsExportQuery {
from: Option<i64>,
to: Option<i64>,
tz: Option<i64>,
user: Option<String>,
/// "xlsx" (all tables) or "csv" (one table: see `table`).
format: Option<String>,
/// csv: total, day, month, model, provider, agent, user, session (default: day).
table: Option<String>,
lang: Option<String>,
}
/// The statistics of a range as a download.
async fn stats_export(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatsExportQuery>) -> Result<Response, ApiError> {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) };
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?;
v["from"] = json!(from);
v["to"] = json!(to);
let de = q.lang.as_deref().is_some_and(|l| l.starts_with("de"));
let tables = crate::stats_export::tables(&v, de);
let stamp = chrono::Local::now().format("%Y-%m-%d");
let (body, ctype, name): (Vec<u8>, &str, String) = if q.format.as_deref() == Some("csv") {
let id = q.table.as_deref().unwrap_or("day");
let t = tables.iter().find(|t| t.id == id).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "unknown table".into()))?;
(crate::stats_export::csv(t, de).into_bytes(), "text/csv; charset=utf-8", format!("codecraft-stats-{id}-{stamp}.csv"))
} else {
let bytes = crate::stats_export::xlsx(&tables).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", format!("codecraft-stats-{stamp}.xlsx"))
};
Ok((
[(axum::http::header::CONTENT_TYPE, ctype.to_string()), (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\""))],
body,
)
.into_response())
}
// --- websocket -------------------------------------------------------------- // --- websocket --------------------------------------------------------------
// --- automations (scheduled agents) ------------------------------------------------ // --- automations (scheduled agents) ------------------------------------------------
@@ -1792,12 +2313,13 @@ struct UpdateJob {
model: Option<String>, model: Option<String>,
permission_mode: Option<String>, permission_mode: Option<String>,
timeout_minutes: Option<u64>, timeout_minutes: Option<u64>,
notify: Option<bool>,
} }
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)?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some(); let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some() || b.notify.is_some();
if changes { if changes {
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation")); let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
if let Some(x) = b.name { if let Some(x) = b.name {
@@ -1821,6 +2343,9 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
if let Some(x) = b.timeout_minutes { if let Some(x) = b.timeout_minutes {
st.timeout_minutes = x; st.timeout_minutes = x;
} }
if let Some(x) = b.notify {
st.notify = x;
}
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.store.job_set_settings(&job.id, &st)?; app.store.job_set_settings(&job.id, &st)?;
app.audit(Some(&user), "job.settings", &job.path, &st.schedule); app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
@@ -1887,6 +2412,9 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
folder: None, folder: None,
instructions, instructions,
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: None,
icon: None,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
st.assistant_session = Some(s.id.clone()); st.assistant_session = Some(s.id.clone());
@@ -1990,6 +2518,57 @@ async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>,
// --- git credentials --------------------------------------------------------------- // --- git credentials ---------------------------------------------------------------
// --- SSH keys -----------------------------------------------------------------
async fn get_ssh_keys(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
#[derive(Deserialize)]
struct NewSshKey {
name: String,
/// An existing private key to import; a new key is generated when missing.
private: Option<String>,
}
async fn add_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewSshKey>) -> ApiResult {
let name = b.name.trim().to_string();
if name.is_empty() || name.chars().count() > 60 {
return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into()));
}
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
if s.keys.len() >= 10 {
return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into()));
}
let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) {
Some(p) => crate::sshkeys::import(&name, p).await,
None => crate::sshkeys::generate(&name).await,
}
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) {
return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into()));
}
s.keys.push(key);
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.add", &name, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn delete_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
let before = s.keys.len();
s.keys.retain(|k| k.id != id);
if s.keys.len() == before {
return Err(not_found("key"));
}
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.delete", &id, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?; let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
Ok(Json(json!(crate::gitcreds::masked(&s)))) Ok(Json(json!(crate::gitcreds::masked(&s))))
@@ -2092,22 +2671,38 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let on: bool = app.store.user_setting(&user.id, "cost_push")?; let on: bool = app.store.user_setting(&user.id, "cost_push")?;
Ok(Json(json!({"enabled": on}))) let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
}
#[derive(Deserialize)]
struct DigestQ {
#[serde(default)]
weekly: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct DigestBody { struct DigestBody {
enabled: bool, enabled: Option<bool>,
weekly: Option<bool>,
} }
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult { async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> ApiResult {
app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?; if let Some(v) = b.enabled {
Ok(Json(json!({"enabled": b.enabled}))) app.store.set_user_setting(&user.id, "cost_push", &v)?;
}
if let Some(v) = b.weekly {
app.store.set_user_setting(&user.id, "weekly_push", &v)?;
}
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?;
Ok(Json(json!({"enabled": on, "weekly": weekly})))
} }
/// Send today's summary right away (preview). /// Send today's summary right away (preview).
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DigestQ>) -> ApiResult {
let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"})); let found = if q.weekly { crate::digest::weekly(&app, &user.id) } else { crate::digest::summary(&app, &user.id) };
let payload = found.unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"}));
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await; let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]}))) Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
} }
+2
View File
@@ -20,6 +20,8 @@ pub enum PermissionReply {
Once, Once,
/// Allow this tool (or bash command prefix) for the rest of the session. /// Allow this tool (or bash command prefix) for the rest of the session.
Always, Always,
/// Like `Always`, and remembered for the project folder (all chats, survives restarts).
Project,
Reject, Reject,
} }
+6
View File
@@ -186,6 +186,12 @@ fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"] ["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter() .iter()
.any(|p| path.ends_with(p)) .any(|p| path.ends_with(p))
// Read-only chat links (the token is the credential).
|| path.starts_with("/public/chat/")
|| path.starts_with("/api/public/chat/")
// MCP bridge for the Claude Code CLI (per-run token).
|| path.starts_with("/mcp-bridge/")
|| path.starts_with("/api/mcp-bridge/")
} }
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response { pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+87
View File
@@ -0,0 +1,87 @@
//! A check command per project folder (tests, build, lint) that runs after every turn that changed
//! files. The result is a green/red badge on the turn; a failed run can be handed to the agent.
use crate::app::App;
use crate::types::{Event, Role, Session, now_ms};
use anyhow::{Result, anyhow, bail};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10 * 60);
const MAX_OUTPUT: usize = 12_000;
/// Last `max` bytes of the output (failures show at the end).
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
format!("…\n{}", &s[start..])
}
fn command(app: &App, session: &Session, script: &str) -> Result<tokio::process::Command> {
let script = format!("exec 2>&1\n{script}");
let cwd = std::path::Path::new(&session.cwd);
if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) {
if !crate::sandbox::available() {
bail!("the sandbox (bubblewrap) is not available");
}
return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script]));
}
let mut c = tokio::process::Command::new("bash");
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
Ok(c)
}
/// Run the project's check for the turn that ended with `message_id` and publish the result.
pub async fn run(app: &Arc<App>, session: &Session, message_id: &str) -> Result<()> {
let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?;
let sid = session.id.clone();
let emit = |status: &str, code: Option<i32>, ms: u64, out: &str| {
let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out);
app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() });
};
emit("running", None, 0, "");
let started = now_ms();
let mut c = match command(app, session, &cmd) {
Ok(c) => c,
Err(e) => {
emit("failed", None, 0, &format!("{e:#}"));
return Ok(());
}
};
let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await;
let ms = (now_ms() - started).max(0) as u64;
match res {
Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)),
Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")),
Ok(Ok(o)) => {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT));
}
}
Ok(())
}
/// After a turn: run the check if the project has one and the turn changed files.
pub fn spawn_after_turn(app: Arc<App>, session_id: String, turn_started: i64) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return };
if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) {
return;
}
let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return };
if let Err(e) = run(&app, &session, &last.id).await {
tracing::debug!("check failed to start: {e:#}");
}
});
}
File diff suppressed because it is too large. Load diff
+23
View File
@@ -231,6 +231,29 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b)) Ok(Json(b))
} }
/// Personal prompt snippets (`/name` in the composer): [{id, name, text}].
pub async fn get_snippets(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "snippets")?;
Ok(Json(if v.is_array() { v } else { json!([]) }))
}
pub async fn put_snippets(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ok = b.as_array().is_some_and(|a| {
a.len() <= 100
&& a.iter().all(|x| {
let name = x["name"].as_str().unwrap_or("");
!name.is_empty() && name.len() <= 40 && !name.contains(char::is_whitespace) && x["text"].as_str().is_some_and(|t| !t.is_empty() && t.len() <= 8000)
})
});
if !ok {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid snippets (name: one word, up to 40 characters; text: up to 8000)".into()));
}
let clean: Vec<Value> = b.as_array().unwrap().iter().map(|x| json!({"id": x["id"].as_str().filter(|i| i.len() <= 64).map(str::to_string).unwrap_or_else(crate::types::new_id), "name": x["name"], "text": x["text"]})).collect();
let v = Value::Array(clean);
app.store.set_user_setting(&user.id, "snippets", &v)?;
Ok(Json(v))
}
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so /// Order and pinning of the groups (folders, projects) in the chat list – per user, so
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}. /// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult { pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
+4
View File
@@ -302,6 +302,9 @@ pub struct ServerConfig {
pub data_dir: String, pub data_dir: String,
pub default_model: String, pub default_model: String,
pub small_model: String, pub small_model: String,
/// Speech-to-text for browsers without dictation (Firefox): "provider/model" of an
/// OpenAI-compatible /audio/transcriptions endpoint, e.g. "openai/whisper-1". Empty = off.
pub transcribe_model: String,
pub auto_compact_threshold: f64, pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only). /// Currency label for cost statistics (display only).
pub currency: String, pub currency: String,
@@ -337,6 +340,7 @@ impl Default for ServerConfig {
data_dir: "./data".into(), data_dir: "./data".into(),
default_model: String::new(), default_model: String::new(),
small_model: String::new(), small_model: String::new(),
transcribe_model: String::new(),
auto_compact_threshold: 0.85, auto_compact_threshold: 0.85,
currency: "USD".into(), currency: "USD".into(),
max_parallel_subagents: 4, max_parallel_subagents: 4,
+49 -1
View File
@@ -2,7 +2,7 @@
//! user via the user setting "cost_push"). //! user via the user setting "cost_push").
use crate::app::App; use crate::app::App;
use chrono::{Local, Timelike}; use chrono::{Datelike, Local, Timelike};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -54,11 +54,59 @@ pub fn summary(app: &App, user_id: &str) -> Option<Value> {
})) }))
} }
/// The last seven days for one user (None when nothing happened).
pub fn weekly(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let since = now.timestamp_millis() - 7 * 86_400_000;
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(since, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
let (chats, failed, checks) = app.store.weekly_counts(user_id, since).ok()?;
if requests == 0 && chats == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = money(stats["total"]["cost"].as_f64().unwrap_or(0.0), &currency, de);
let top = stats["top_sessions"].as_array().and_then(|a| a.first()).and_then(|s| s["title"].as_str()).filter(|t| !t.is_empty()).map(str::to_string);
let mut parts = if de { vec![format!("{chats} Chats aktiv"), format!("{requests} Anfragen · {cost}")] } else { vec![format!("{chats} chats active"), format!("{requests} requests · {cost}")] };
if checks > 0 {
parts.push(if de { format!("Checks: {failed} von {checks} fehlgeschlagen") } else { format!("checks: {failed} of {checks} failed") });
}
if let Some(t) = top {
parts.push(if de { format!("teuerster Chat: {t}") } else { format!("most expensive chat: {t}") });
}
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Wochenrückblick" } else { "CodeCraft – weekly review" },
"body": parts.join("\n"),
"tag": "weekly-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) { pub fn spawn(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now(); let now = Local::now();
// Weekly review: Monday morning, opt-in (user setting "weekly_push").
if now.hour() == 8 && now.weekday() == chrono::Weekday::Mon {
let week = now.format("%G-W%V").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "weekly_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "weekly_push_last").unwrap_or_default();
if !on || user.disabled || last == week {
continue;
}
let _ = app.store.set_user_setting(&user.id, "weekly_push_last", &week);
if let Some(payload) = weekly(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "weekly review");
}
}
}
if now.hour() != HOUR { if now.hour() != HOUR {
continue; continue;
} }
+13 -2
View File
@@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult { pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id)); let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user); let cfg = crate::gitcreds::global_config(&app, &user);
let env = crate::gitcreds::identity_env(&app, &user.id); let mut env = crate::gitcreds::identity_env(&app, &user.id);
env.extend(crate::sshkeys::env(&app, &user));
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
} }
@@ -254,6 +255,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
git_ok(&repo, &args, 60).await?; git_ok(&repo, &args, 60).await?;
json!({"ok": true}) json!({"ok": true})
} }
"suggest" => {
let diff = git_ok(&repo, &["diff", "--cached", "--no-color", "--no-ext-diff", "--stat", "--patch", "-U2"], 60).await?;
if diff.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "nothing is staged".into()));
}
let recent = git_ok(&repo, &["log", "-n", "8", "--pretty=%s"], 15).await.unwrap_or_default();
let message = crate::runner::suggest_commit_message(&app, &user.id, &diff, &recent).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, e.to_string()))?;
json!({"message": message})
}
"commit" => { "commit" => {
let msg = b.message.as_deref().unwrap_or("").trim().to_string(); let msg = b.message.as_deref().unwrap_or("").trim().to_string();
if msg.is_empty() && !b.amend { if msg.is_empty() && !b.amend {
@@ -392,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> { pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id)); let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user); let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id); let mut env = crate::gitcreds::identity_env(app, &user.id);
env.extend(crate::sshkeys::env(app, user));
let s = session.clone(); let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?; let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) { if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
+42
View File
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
env env
} }
/// What an agent needs to know about the stored git access (no secrets): git already has the
/// credentials, so it must not look for them. None when nothing is stored.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
}
fn render_prompt(s: &GitSettings) -> Option<String> {
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
if creds.is_empty() {
return None;
}
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
for c in &creds {
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
}
t.push_str(
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
Use https:// remote URLs (SSH keys are not set up). \
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
);
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
}
Some(t)
}
pub fn home_of(app: &App, user: &User) -> PathBuf { pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home") app.data_dir().join("users").join(&user.username).join("home")
} }
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn prompt_names_hosts_but_never_tokens() {
assert!(render_prompt(&GitSettings::default()).is_none());
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
let p = render_prompt(&settings).unwrap();
println!("{p}");
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
assert!(!p.contains("s3cret-token"));
// Entries without a token are not offered.
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
assert!(render_prompt(&none).is_none());
}
#[test] #[test]
fn keeps_masked_tokens() { fn keeps_masked_tokens() {
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() }; let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
+13 -2
View File
@@ -42,8 +42,9 @@ pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderSta
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0), Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0), Err(_) => (Some("timed out".to_string()), 0),
}; };
// The host answered but has no model list: reachable. // The host answered but has no model list: reachable. (fal.ai's router answers 403
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405"))); // "endpoint_not_priced" for /models: the key is fine, the listing is just not offered.)
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
let state = match &error { let state = match &error {
None => "ok", None => "ok",
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth", Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
@@ -83,3 +84,13 @@ pub fn spawn_checker(app: Arc<App>) {
} }
}); });
} }
#[cfg(test)]
mod tests {
#[test]
fn unpriced_model_listing_is_not_an_auth_failure() {
let e = r#"provider returned HTTP 403 Forbidden: {"detail":"Endpoint openrouter/router/openai/v1/models is not available: no billing definition.","type":"endpoint_not_priced"}"#.to_string();
let kept = Some(e).filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced")));
assert!(kept.is_none());
}
}
+49
View File
@@ -68,6 +68,13 @@ pub struct JobSettings {
/// Chat in which the user works out the prompt with a model. /// Chat in which the user works out the prompt with a model.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, pub assistant_session: Option<String>,
/// Push notification with the report when a run ends.
#[serde(default = "default_notify")]
pub notify: bool,
}
fn default_notify() -> bool {
true
} }
impl JobSettings { impl JobSettings {
@@ -81,6 +88,7 @@ impl JobSettings {
timeout_minutes: default_timeout(), timeout_minutes: default_timeout(),
cwd: None, cwd: None,
assistant_session: None, assistant_session: None,
notify: true,
} }
} }
@@ -121,6 +129,7 @@ pub struct JobSpec {
pub cwd: Option<String>, pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>, pub assistant_session: Option<String>,
pub notify: bool,
pub prompt: String, pub prompt: String,
} }
@@ -135,6 +144,7 @@ impl JobSpec {
timeout_minutes: s.timeout_minutes, timeout_minutes: s.timeout_minutes,
cwd: s.cwd.clone(), cwd: s.cwd.clone(),
assistant_session: s.assistant_session.clone(), assistant_session: s.assistant_session.clone(),
notify: s.notify,
prompt, prompt,
} }
} }
@@ -149,6 +159,7 @@ impl JobSpec {
timeout_minutes: self.timeout_minutes, timeout_minutes: self.timeout_minutes,
cwd: self.cwd.clone(), cwd: self.cwd.clone(),
assistant_session: self.assistant_session.clone(), assistant_session: self.assistant_session.clone(),
notify: self.notify,
} }
} }
} }
@@ -218,6 +229,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None, assistant_session: None,
notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true),
prompt: body.trim().to_string(), prompt: body.trim().to_string(),
}) })
} }
@@ -321,6 +333,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
instructions: String::new(), instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true, telegram_quiet: true,
summary: String::new(),
color: None,
icon: None,
}; };
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)?;
@@ -339,6 +354,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
let job_id = job.id.clone(); let job_id = job.id.clone();
let sid = session.id.clone(); let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60); let timeout = Duration::from_secs(spec.timeout_minutes * 60);
let (spec_name, spec_notify) = (spec.name.clone(), spec.notify);
tokio::spawn(async move { tokio::spawn(async move {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
@@ -346,15 +362,48 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
if started.elapsed() > timeout { if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid); crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout"); let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
report(&app2, &sid, &spec_name, spec_notify, "timeout").await;
return; return;
} }
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
let _ = app2.store.job_finished(&job_id, Some(&sid), "done"); let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
report(&app2, &sid, &spec_name, spec_notify, "done").await;
}); });
Ok(session.id) Ok(session.id)
} }
/// Push the report of a finished run (the agent's last message) to the owner.
async fn report(app: &App, session_id: &str, name: &str, notify: bool, status: &str) {
if !notify {
return;
}
let Ok(session) = app.session(session_id) else { return };
let Some(owner) = session.owner_id.clone() else { return };
let prefs: Value = app.store.user_setting(&owner, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let last = app
.store
.messages(session_id)
.unwrap_or_default()
.into_iter()
.rev()
.find(|m| matches!(m.role, crate::types::Role::Assistant))
.map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n"))
.unwrap_or_default();
let flat: String = last.split_whitespace().collect::<Vec<_>>().join(" ");
let body: String = if flat.chars().count() > 220 { format!("{}…", flat.chars().take(220).collect::<String>()) } else { flat };
let title = match (status, de) {
("timeout", true) => format!("{name} – Zeitlimit erreicht"),
("timeout", false) => format!("{name} – timed out"),
(_, true) => format!("{name} – fertig"),
(_, false) => format!("{name} – finished"),
};
let payload = json!({"kind": "job", "title": title, "body": body, "tag": format!("job-{session_id}"), "url": format!("/#/s/{session_id}")});
let (sent, _) = crate::push::send_to_user(app, &owner, &payload).await;
tracing::info!(job = name, sent, "automation report");
}
/// Background scheduler: checks every 30 s which jobs are due. /// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) { pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move { tokio::spawn(async move {
+5
View File
@@ -5,6 +5,8 @@ mod app;
mod auth; mod auth;
mod backup; mod backup;
mod blobs; mod blobs;
mod checks;
mod claude_code;
mod cli_api; mod cli_api;
mod budget; mod budget;
mod config; mod config;
@@ -22,6 +24,7 @@ mod lsp;
mod mentions; mod mentions;
mod metrics; mod metrics;
mod mcp; mod mcp;
mod mcp_bridge;
mod media; mod media;
mod mesh3d; mod mesh3d;
mod permission; mod permission;
@@ -32,6 +35,8 @@ mod reverse;
mod runner; mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod sshkeys;
mod stats_export;
mod store; mod store;
mod telegram; mod telegram;
mod terminal; mod terminal;
+252
View File
@@ -0,0 +1,252 @@
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
//!
//! The CLI only knows its own tools. For every run the server registers a secret token and
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
//! credential (the route is public) and binds the calls to the chat that started the run.
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
//! cards, rules and project approvals work as for the CLI's own tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
pub const SERVER: &str = "codecraft";
/// Prefix the CLI gives our tools.
pub const PREFIX: &str = "mcp__codecraft__";
/// Our tools the CLI brings itself (same job): not offered twice.
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
/// A registered run; the token stops working when this is dropped.
pub struct Registration {
pub token: String,
}
impl Drop for Registration {
fn drop(&mut self) {
TOKENS.lock().unwrap().remove(&self.token);
}
}
pub fn register(session_id: &str) -> Registration {
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
Registration { token }
}
/// `--mcp-config` value for a run.
pub fn config_json(app: &App, token: &str) -> String {
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
}
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
/// the server's MCP tools …) without the ones the CLI has itself.
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
}
/// Short note for the system prompt: what the extra tools are called.
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
if tools.is_empty() {
return None;
}
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
Some(format!(
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
names.join(", ")
))
}
// --- protocol ----------------------------------------------------------------------------
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
}
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
match res {
Ok(out) => {
let mut content = vec![json!({"type": "text", "text": out.content})];
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
}
json!({"content": content, "isError": out.is_error})
}
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
}
}
fn answer(id: &Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn failure(id: &Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
}
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
// Notifications have no answer.
if req.get("id").is_none() || method.starts_with("notifications/") {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => answer(
&id,
json!({
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
}),
),
"ping" => answer(&id, json!({})),
"tools/list" | "tools/call" => {
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
let tools = tools_for(&app, &session);
if method == "tools/list" {
return answer(&id, tools_list(&tools));
}
let name = req["params"]["name"].as_str().unwrap_or("");
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
return failure(&id, -32602, &format!("unknown tool '{name}'"));
};
let cwd = PathBuf::from(&session.cwd);
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.clone(),
call_id: crate::types::new_id(),
cwd,
cancel: CancellationToken::new(),
agent: agent.name.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
owner_id: None,
};
let input = req["params"]["arguments"].clone();
let input = if input.is_null() { json!({}) } else { input };
answer(&id, call_result(tool.run(input, &ctx).await))
}
_ => failure(&id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ImageData;
#[test]
fn registration_lives_as_long_as_the_run() {
let r = register("s1");
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
let t = r.token.clone();
drop(r);
assert!(!TOKENS.lock().unwrap().contains_key(&t));
}
#[test]
fn tool_results_carry_text_and_images() {
let out = ToolOutput {
content: "saved".into(),
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
..Default::default()
};
let v = call_result(Ok(out));
assert_eq!(v["isError"], false);
let c = v["content"].as_array().unwrap();
assert_eq!(c.len(), 2); // the image without data is left out
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
}
#[test]
fn cli_builtins_are_not_offered_twice() {
let all = crate::tools::builtin_tools();
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
assert!(ours.contains(&n), "{n} missing");
}
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
assert!(list["tools"][0]["inputSchema"].is_object());
}
/// The whole protocol against a real App: initialize, list, call, unknown token.
#[tokio::test]
async fn protocol_roundtrip_with_a_real_app() {
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
std::fs::create_dir_all(dir.join("ws")).unwrap();
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
else { l.to_string() }
}).collect::<Vec<_>>().join("\n");
let cfg = crate::config::Config::parse(&toml).unwrap();
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
let now = crate::types::now_ms();
let session = crate::types::Session {
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
parent_id: None, owner_id: None, created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None,
pinned: false, archived: false, folder: None, instructions: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
};
let session = app.save_session(session).unwrap();
let reg = register(&session.id);
let rpc = |req: Value| {
let app = app.clone();
let token = reg.token.clone();
async move {
let resp = handle(State(app), Path(token), Json(req)).await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
}
};
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(init["result"]["capabilities"]["tools"].is_object());
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
assert_eq!(st, StatusCode::ACCEPTED);
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
// A real tool call: `remember` writes the project memory.
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
assert_eq!(call["result"]["isError"], false, "{call}");
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
assert!(mem.contains("bridge works"), "{mem}");
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
assert!(bad["error"].is_object());
// A finished run's token is dead.
let token = reg.token.clone();
drop(reg);
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(&dir);
}
}
+6
View File
@@ -298,6 +298,12 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
} }
} }
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
s.push_str(&git);
}
if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) {
s.push_str(&ssh);
}
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())), Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
+12
View File
@@ -61,9 +61,21 @@ impl Registry {
ProviderEntry { name: name.clone(), provider, models }, ProviderEntry { name: name.clone(), provider, models },
); );
} }
// Claude Code (the CLI's own agent) appears as a model list when it is installed.
if !providers.contains_key(crate::claude_code::PROVIDER) && crate::claude_code::binary().is_some() {
providers.insert(
crate::claude_code::PROVIDER.to_string(),
ProviderEntry { name: crate::claude_code::PROVIDER.to_string(), provider: Arc::new(crate::claude_code::Marker), models: crate::claude_code::models() },
);
}
Self { providers, limits } Self { providers, limits }
} }
/// First model of any provider but `skip`.
pub fn first_model_excluding(&self, skip: &str) -> Option<String> {
self.providers.values().filter(|e| e.name != skip).find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
}
/// Wait for a free slot on the model and its provider. Always acquired in the /// Wait for a free slot on the model and its provider. Always acquired in the
/// same order (model, then provider), so concurrent requests cannot deadlock. /// same order (model, then provider), so concurrent requests cannot deadlock.
pub async fn acquire(&self, spec: &str) -> Slot { pub async fn acquire(&self, spec: &str) -> Slot {
+7 -1
View File
@@ -200,8 +200,12 @@ pub fn spawn_listener(app: Arc<App>) {
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break, Err(_) => break,
}; };
let mut permission_id = None;
let (session_id, kind, body) = match &ev { let (session_id, kind, body) = match &ev {
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")), Event::PermissionRequest { id, session_id, title, tool, .. } => {
permission_id = Some(id.clone());
(session_id.clone(), "permission", format!("{tool}: {title}"))
}
Event::QuestionRequest { session_id, questions, .. } => { Event::QuestionRequest { session_id, questions, .. } => {
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default(); let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
(session_id.clone(), "question", q) (session_id.clone(), "question", q)
@@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc<App>) {
continue; continue;
} }
let payload = json!({ let payload = json!({
// Lets the notification itself approve or reject (service worker → POST /api/permissions/<id>).
"permission_id": permission_id,
"kind": kind, "kind": kind,
"title": title, "title": title,
"body": body.chars().take(180).collect::<String>(), "body": body.chars().take(180).collect::<String>(),
+257 -8
View File
@@ -2,7 +2,7 @@
//! checks), feed results back, repeat until the model stops. //! checks), feed results back, repeat until the model stops.
use crate::app::{App, PermissionReply}; use crate::app::{App, PermissionReply};
use crate::permission::{Decision, Evaluator, bash_prefix, split_command}; use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
use crate::prompts::{self, Agent}; use crate::prompts::{self, Agent};
use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord; use crate::store::UsageRecord;
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.join("\n"); .join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string(); let source = source.to_string();
let turn_started = now_ms();
tokio::spawn(async move { tokio::spawn(async move {
let res = async { let res = async {
@@ -114,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) } if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
}); });
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
if leftover.is_empty() && !cancel.is_cancelled() {
spawn_summary(app.clone(), session_id.clone());
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
}
if !leftover.is_empty() { if !leftover.is_empty() {
let _ = start_prompt(app, session_id, leftover); let _ = start_prompt(app, session_id, leftover);
} }
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
/// Messages since the last compaction summary. Tool calls left without a /// Messages since the last compaction summary. Tool calls left without a
/// result (run interrupted, e.g. by a server restart) get a synthetic result: /// result (run interrupted, e.g. by a server restart) get a synthetic result:
/// providers reject histories with unanswered tool calls. /// providers reject histories with unanswered tool calls.
fn active_history(all: Vec<Message>) -> Vec<Message> { pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
let start = all let start = all
.iter() .iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
@@ -263,6 +268,10 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text. /// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> { pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
return crate::claude_code::run(app, session_id, cancel).await;
}
let mut final_text = String::new(); let mut final_text = String::new();
let mut stop_hooks = 0; let mut stop_hooks = 0;
let mut refine_done = 0u32; let mut refine_done = 0u32;
@@ -639,9 +648,18 @@ async fn execute_call(
// Permission check. Approvals are tracked on the top-level session. // Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id); let root = app.root_session_id(&session.id);
let cfg = app.config(); let cfg = app.config();
let decision = app.with_runtime(&root, |r| { let (mut decision, session_approved) = app.with_runtime(&root, |r| {
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) (Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
}); });
// What the user allowed "for this project" earlier counts as well.
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = session_approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
}
}
match decision { match decision {
Decision::Deny => { Decision::Deny => {
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None); return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
@@ -659,16 +677,27 @@ async fn execute_call(
} }
return tool_result(call_id, call_name, text, true, None); return tool_result(call_id, call_name, text, true, None);
} }
PermissionReply::Always => app.with_runtime(&root, |r| { PermissionReply::Always | PermissionReply::Project => {
// (key, may be remembered for the whole project)
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" { if name == "bash" {
let cmd = input["command"].as_str().unwrap_or(""); let cmd = input["command"].as_str().unwrap_or("");
for part in split_command(cmd).unwrap_or_default() { for part in split_command(cmd).unwrap_or_default() {
r.approved.insert(format!("bash:{}", bash_prefix(&part))); // Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
} }
} else { } else {
r.approved.insert(name.to_string()); keys.push((name.to_string(), true));
}
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
} }
}),
PermissionReply::Once => {} PermissionReply::Once => {}
} }
if let Some(sel) = hunks { if let Some(sel) = hunks {
@@ -868,6 +897,9 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
folder: None, folder: None,
instructions: String::new(), instructions: String::new(),
telegram_quiet: false, telegram_quiet: false,
summary: String::new(),
color: None,
icon: None,
}; };
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 }]);
@@ -1149,6 +1181,9 @@ fn small_model(app: &App, session: &Session) -> String {
let cfg = app.config(); let cfg = app.config();
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone() cfg.server.small_model.clone()
} else if crate::claude_code::is_claude_code(&session.model) {
// Claude Code runs itself; side tasks need an ordinary model.
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
} else { } else {
session.model.clone() session.model.clone()
} }
@@ -1176,6 +1211,174 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
}); });
} }
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
pub(crate) fn fit(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let head = clip(s, max * 2 / 3);
let mut start = s.len() - max / 3;
while !s.is_char_boundary(start) {
start += 1;
}
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
}
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
let spec = small_model(app, session);
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let ctx = model.context.unwrap_or(32_000);
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
record_side_usage(app, session, &spec, &model, &usage, kind);
Ok(strip_think(&text))
}
/// A commit message for staged changes (small model; the diff is cut to its context window).
/// `recent` are the latest commit subjects, so the style matches the repository.
pub async fn suggest_commit_message(app: &App, user_id: &str, diff: &str, recent: &str) -> Result<String> {
let cfg = app.config();
let spec = if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else {
app.registry().first_model_excluding(crate::claude_code::PROVIDER).ok_or_else(|| anyhow!("no model available"))?
};
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let max_out = 200u64;
let system = "You write a git commit message for the staged changes. Reply with the message only: a subject line of at most 72 characters in the imperative mood, then optionally a blank line and up to 4 short bullet points on what changed and why. No quotes, no code fences. Match the style of the recent commit subjects (language, prefixes).";
let ctx = model.context.unwrap_or(32_000);
let room = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 500).max(600) as usize * 5 / 2;
let input = format!("<recent-commits>\n{}\n</recent-commits>\n<staged-diff>\n{}\n</staged-diff>", fit(recent, 600), fit(diff, room.saturating_sub(700).clamp(500, 40_000)));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: None,
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: "cli:git-commit".into(),
root_session_id: "cli:git-commit".into(),
provider: p.into(),
model: m.into(),
agent: "commit-message".into(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
ttft_ms: 0,
duration_ms: 0,
cost: model.cost(&usage),
error: false,
user_id: Some(user_id.to_string()),
allowance: usage.allowance,
});
let msg = strip_think(&text).trim().trim_matches('`').trim().to_string();
if msg.is_empty() {
bail!("the model returned no message");
}
Ok(msg)
}
fn message_text(m: &Message) -> String {
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
}
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
fn spawn_summary(app: Arc<App>, session_id: String) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(history) = app.store.messages(&session_id) else { return };
let history = active_history(history);
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
let input = format!(
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
session.summary,
fit(&message_text(u), 1500),
fit(&message_text(a), 2500)
);
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
match side_task(&app, &session, "summary", system, &input, 120).await {
Ok(text) => {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
if !line.is_empty() {
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
}
}
Err(e) => tracing::debug!("summary failed: {e:#}"),
}
});
}
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
let language = if lang.starts_with("de") { "German" } else { "English" };
let system = format!(
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
If it is harmless, say so. No markdown headings, no code blocks."
);
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
if let Some(d) = diff {
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
}
side_task(app, session, "explain", &system, &body, 300).await
}
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
/// Works with a small model: the transcript is cut to its context window.
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
let history = active_history(app.store.messages(&session.id)?);
let transcript = transcript_of(&history);
if transcript.trim().is_empty() {
bail!("the session has no messages yet");
}
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
Use the language of the user's messages. Keep it under 300 words.";
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
let first = lines.next().unwrap_or("");
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
None => (String::new(), text.clone()),
};
let body = body.trim().to_string();
if body.is_empty() {
bail!("the model returned no instructions");
}
let name = if name.is_empty() { session.title.clone() } else { name };
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
}
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) { fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
let Ok(session) = app.session(session_id) else { return }; let Ok(session) = app.session(session_id) else { return };
if session.parent_id.is_some() || !session.title.is_empty() { if session.parent_id.is_some() || !session.title.is_empty() {
@@ -1520,10 +1723,56 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
Ok(()) Ok(())
} }
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
/// compaction without a summary: history before the marker is not sent any more.
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
app.update_session(session_id, |s| {
s.last_usage = None;
s.todos = Value::Null;
})?;
app.with_runtime(session_id, |r| r.read_files.clear());
// Claude Code continues its own conversation: start a new one.
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn cleared_marker_starts_a_new_history() {
let mk = |role, parts| Message::new(role, parts);
let all = vec![
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
];
let active = active_history(all);
assert_eq!(active.len(), 2);
assert!(!active.iter().any(|m| message_text(m).contains("old")));
}
#[test]
fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
let out = fit(&text, 3000);
assert!(out.len() <= 3100, "{}", out.len());
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
// Multi-byte text is cut on character boundaries.
let de = "äöüß".repeat(2000);
assert!(fit(&de, 1000).is_char_boundary(0));
assert_eq!(fit("short", 100), "short");
}
#[test] #[test]
fn todo_completion_and_reopening() { fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]); let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
+8 -1
View File
@@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
if new_session { if new_session {
push(&[&"--new-session"]); push(&[&"--new-session"]);
} }
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"); // SSH wrappers (user's keys); only when keys are set up.
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
let npm = format!("{home}/.npm-global"); let npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx"); let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin"); let pipx_bin = format!("{home}/.local/bin");
@@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] { ] {
push(&[&"--setenv", &k, &v]); push(&[&"--setenv", &k, &v]);
} }
if ssh_wrapper.exists() {
let cmd = format!("{ssh_bin}/ssh");
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
}
for (k, v) in &spec.env { for (k, v) in &spec.env {
push(&[&"--setenv", k, v]); push(&[&"--setenv", k, v]);
} }
+299
View File
@@ -0,0 +1,299 @@
//! Per-user SSH keys for agents, terminals and the git panel.
//!
//! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git
//! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would
//! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH
//! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two
//! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins;
//! absolute host paths).
use crate::app::App;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshKey {
pub id: String,
pub name: String,
pub public: String,
pub fingerprint: String,
/// OpenSSH private key (never sent to the UI).
#[serde(default)]
pub private: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshSettings {
#[serde(default)]
pub keys: Vec<SshKey>,
}
/// Keys without the private part, for the UI.
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect()
}
fn ssh_dir(home: &Path) -> PathBuf {
home.join(".ssh")
}
/// Wrappers for unsandboxed runs.
fn bin_dir(home: &Path) -> PathBuf {
home.join(".codecraft-bin")
}
/// Wrappers inside the sandbox (see `sandbox::bwrap_args`).
pub const SANDBOX_BIN: &str = ".codecraft-sbin";
fn key_file(id: &str) -> String {
format!("codecraft_{id}")
}
/// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different
/// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs).
pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String {
let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n");
for k in keys {
if sandbox {
c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id)));
} else {
c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display()));
}
}
// Trust a host on first contact and refuse changed keys afterwards.
c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n");
if sandbox {
c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n");
} else {
c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display()));
}
c
}
#[cfg(unix)]
fn chmod(p: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn chmod(_: &Path, _: u32) -> Result<()> {
Ok(())
}
/// Write keys, config and wrappers into the user's home (removes files of deleted keys).
pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
let home = crate::gitcreds::home_of(app, user);
let dir = ssh_dir(&home);
std::fs::create_dir_all(&dir)?;
chmod(&dir, 0o700)?;
// Old key files.
for e in std::fs::read_dir(&dir)?.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) {
let _ = std::fs::remove_file(e.path());
}
}
if s.keys.is_empty() {
// No keys: no wrappers either, ssh behaves as usual.
let _ = std::fs::remove_dir_all(bin_dir(&home));
let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN));
for f in ["config-sandbox", "config-host"] {
let _ = std::fs::remove_file(dir.join(f));
}
return Ok(());
}
for k in &s.keys {
let p = dir.join(key_file(&k.id));
// The file is replaced, not appended: remove it first so the mode applies.
let _ = std::fs::remove_file(&p);
std::fs::write(&p, &k.private)?;
chmod(&p, 0o600)?;
let pubp = dir.join(format!("{}.pub", key_file(&k.id)));
std::fs::write(&pubp, format!("{}\n", k.public.trim()))?;
chmod(&pubp, 0o644)?;
}
// The user's own ~/.ssh/config (if any) is included after the managed part.
let own = dir.join("config");
let sandbox_cfg = dir.join("config-sandbox");
std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?;
chmod(&sandbox_cfg, 0o600)?;
let host_cfg = dir.join("config-host");
std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?;
chmod(&host_cfg, 0o600)?;
for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] {
std::fs::create_dir_all(&bin)?;
for name in ["ssh", "scp", "sftp"] {
// The image's /usr/local/bin wrappers cope with a uid without passwd entry.
let local = format!("/usr/local/bin/{name}");
let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") };
let p = bin.join(name);
std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?;
chmod(&p, 0o755)?;
}
}
Ok(())
}
/// Environment for processes that run without the sandbox: the wrappers first in PATH, git over
/// SSH through the wrapper. Empty when the user has no keys.
pub fn env(app: &App, user: &User) -> Vec<(String, String)> {
let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default();
if s.keys.is_empty() {
return Vec::new();
}
let bin = bin_dir(&crate::gitcreds::home_of(app, user));
if !bin.join("ssh").exists() {
return Vec::new();
}
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()));
vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())]
}
/// What an agent needs to know (public keys only). None without keys.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
}
fn render_prompt(s: &SshSettings) -> Option<String> {
if s.keys.is_empty() {
return None;
}
let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect();
Some(format!(
"\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \
no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \
if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \
Commands run without a terminal, so password or passphrase prompts fail.\n",
names.join(", ")
))
}
// --- creating keys ---------------------------------------------------------------------
async fn keygen(args: &[&str], stdin: Option<&str>) -> Result<String> {
let mut cmd = Command::new("ssh-keygen");
cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?;
if let Some(mut si) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
if let Some(t) = stdin {
let _ = si.write_all(t.as_bytes()).await;
}
}
let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn temp_dir() -> Result<PathBuf> {
let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id()));
std::fs::create_dir_all(&d)?;
chmod(&d, 0o700)?;
Ok(d)
}
/// A new ed25519 key without passphrase.
pub async fn generate(name: &str) -> Result<SshKey> {
let d = temp_dir()?;
let f = d.join("key");
let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-"));
let res = async {
keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?;
let private = std::fs::read_to_string(&f)?;
let public = std::fs::read_to_string(d.join("key.pub"))?;
finish(name, private, public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
/// An existing private key (OpenSSH or PEM, without passphrase).
pub async fn import(name: &str, private: &str) -> Result<SshKey> {
let mut text = private.trim().replace("\r\n", "\n");
if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") {
bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)");
}
text.push('\n');
let d = temp_dir()?;
let f = d.join("key");
let res = async {
std::fs::write(&f, &text)?;
chmod(&f, 0o600)?;
let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?;
finish(name, text.clone(), public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
async fn finish(name: &str, private: String, public: String) -> Result<SshKey> {
let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default();
let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string();
Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private })
}
#[cfg(test)]
mod tests {
use super::*;
fn key(id: &str) -> SshKey {
SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() }
}
#[test]
fn config_has_both_path_styles() {
let home = Path::new("/data/users/ann/home");
let t = render_config(&[key("k1")], home, true);
assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts"));
assert!(t.contains("StrictHostKeyChecking accept-new"));
let a = render_config(&[key("k1")], home, false);
assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1"));
assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts"));
}
#[test]
fn sandbox_config_resolves_through_home() {
if std::process::Command::new("ssh").arg("-V").output().is_err() {
return;
}
let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("cfg");
std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap();
let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
// `-G` shows identity files unexpanded (expanded when used, ssh_config(5)).
assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}");
assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn prompt_and_ui_view_never_contain_the_private_key() {
let s = SshSettings { keys: vec![key("k1")] };
assert!(masked(&s).iter().all(|k| k.private.is_empty()));
let p = render_prompt(&s).unwrap();
assert!(p.contains("laptop") && !p.contains("SECRET"));
assert!(render_prompt(&SshSettings::default()).is_none());
}
#[tokio::test]
async fn generated_and_imported_keys_match() {
if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() {
return; // not installed here
}
let k = generate("test key").await.unwrap();
assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY"));
let again = import("copy", &k.private).await.unwrap();
assert_eq!(again.fingerprint, k.fingerprint);
assert!(import("bad", "hello").await.is_err());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Statistics as CSV (one table) or XLSX (all tables, one sheet each).
use serde_json::Value;
use std::io::Write;
/// A table: header row and rows of cells.
pub struct Table {
pub id: &'static str,
pub title: String,
pub rows: Vec<Vec<Cell>>,
}
pub enum Cell {
Text(String),
Num(f64),
}
fn num(v: &Value, k: &str) -> Cell {
Cell::Num(v[k].as_f64().unwrap_or(0.0))
}
/// Column titles (English, German).
fn columns(first: &str, de: bool) -> Vec<Cell> {
let t: [&str; 10] = if de {
["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"]
} else {
["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"]
};
std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect()
}
fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table {
let mut out = vec![columns(first, de)];
for r in rows.as_array().into_iter().flatten() {
out.push(vec![
Cell::Text(r[key].as_str().unwrap_or("").to_string()),
num(r, "requests"),
num(r, "input_tokens"),
num(r, "cache_read_tokens"),
num(r, "cache_write_tokens"),
num(r, "output_tokens"),
num(r, "errors"),
num(r, "avg_ttft_ms"),
num(r, "output_tps"),
num(r, "prefill_tps"),
num(r, "cost"),
]);
}
Table { id, title: title.to_string(), rows: out }
}
/// All tables of a statistics result (as returned by `Store::global_stats`).
pub fn tables(stats: &Value, de: bool) -> Vec<Table> {
let (day, month, model, provider, agent, user, session) = if de {
("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat")
} else {
("Day", "Month", "Model", "Provider", "Agent", "User", "Chat")
};
let mut out = vec![
agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de),
agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de),
agg_table("model", model, model, "model", &stats["by_model"], de),
agg_table("provider", provider, provider, "provider", &stats["by_provider"], de),
agg_table("agent", agent, agent, "agent", &stats["by_agent"], de),
agg_table("user", user, user, "user", &stats["by_user"], de),
agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de),
];
// The grand total first, as a one-row table.
let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)];
let t = &stats["total"];
total.push(vec![
Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))),
num(t, "requests"),
num(t, "input_tokens"),
num(t, "cache_read_tokens"),
num(t, "cache_write_tokens"),
num(t, "output_tokens"),
num(t, "errors"),
num(t, "avg_ttft_ms"),
num(t, "output_tps"),
num(t, "prefill_tps"),
num(t, "cost"),
]);
out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total });
out
}
fn date(ms: i64) -> String {
if ms <= 0 {
return "-".into();
}
chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
// --- CSV -----------------------------------------------------------------------------
/// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise;
/// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection.
pub fn csv(table: &Table, de: bool) -> String {
let sep = if de { ';' } else { ',' };
let mut out = String::from('\u{feff}');
for row in &table.rows {
let line: Vec<String> = row
.iter()
.map(|c| match c {
Cell::Num(n) => {
let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") };
if de { s.replace('.', ",") } else { s }
}
Cell::Text(t) => {
let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() };
if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t }
}
})
.collect();
out.push_str(&line.join(&sep.to_string()));
out.push_str("\r\n");
}
out
}
// --- XLSX ----------------------------------------------------------------------------
fn esc(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.map(|c| match c {
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
c => c.to_string(),
})
.collect()
}
fn col_name(mut i: usize) -> String {
let mut s = String::new();
loop {
s.insert(0, (b'A' + (i % 26) as u8) as char);
if i < 26 {
break;
}
i = i / 26 - 1;
}
s
}
fn sheet_xml(t: &Table) -> String {
let mut x = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="11" width="16" customWidth="1"/></cols><sheetData>"#);
for (r, row) in t.rows.iter().enumerate() {
x.push_str(&format!(r#"<row r="{}">"#, r + 1));
for (c, cell) in row.iter().enumerate() {
let at = format!("{}{}", col_name(c), r + 1);
let style = if r == 0 { r#" s="1""# } else { "" };
match cell {
// Text as inline strings: no shared string table needed.
Cell::Text(s) => x.push_str(&format!(r#"<c r="{at}" t="inlineStr"{style}><is><t xml:space="preserve">{}</t></is></c>"#, esc(s))),
Cell::Num(n) => x.push_str(&format!(r#"<c r="{at}"{style}><v>{n}</v></c>"#)),
}
}
x.push_str("</row>");
}
x.push_str("</sheetData></worksheet>");
x
}
/// Sheet names: at most 31 characters, none of []:*?/\
fn sheet_name(s: &str) -> String {
s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect()
}
pub fn xlsx(tables: &[Table]) -> anyhow::Result<Vec<u8>> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut put = |name: &str, body: &str| -> anyhow::Result<()> {
z.start_file(name, opts)?;
z.write_all(body.as_bytes())?;
Ok(())
};
let mut types = String::from(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#);
let mut sheets = String::new();
let mut rels = String::new();
for (i, t) in tables.iter().enumerate() {
let n = i + 1;
types.push_str(&format!(r#"<Override PartName="/xl/worksheets/sheet{n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#));
sheets.push_str(&format!(r#"<sheet name="{}" sheetId="{n}" r:id="rId{n}"/>"#, esc(&sheet_name(&t.title))));
rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.xml"/>"#));
}
types.push_str("</Types>");
let styles_id = tables.len() + 1;
rels.push_str(&format!(r#"<Relationship Id="rId{styles_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
put("[Content_Types].xml", &types)?;
put("_rels/.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#)?;
put("xl/workbook.xml", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
put("xl/_rels/workbook.xml.rels", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#))?;
// Style 1 = bold header.
put("xl/styles.xml", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>"#)?;
for (i, t) in tables.iter().enumerate() {
put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?;
}
z.finish()?;
}
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> Value {
json!({
"from": 0, "to": 1_700_000_000_000i64,
"total": {"requests": 3, "cost": 1.5},
"by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}],
"by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}],
"by_provider": [], "by_agent": [], "by_user": [], "top_sessions": []
})
}
#[test]
fn csv_uses_locale_and_blocks_formulas() {
let tabs = tables(&sample(), true);
let day = tabs.iter().find(|t| t.id == "day").unwrap();
let c = csv(day, true);
assert!(c.starts_with('\u{feff}'));
assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}");
let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false);
assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}");
}
#[test]
fn xlsx_is_a_zip_with_every_sheet() {
let tabs = tables(&sample(), false);
let bytes = xlsx(&tabs).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
for i in 1..=tabs.len() {
assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok());
}
let mut wb = String::new();
std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap();
assert!(wb.contains("Per day") && wb.contains("Total"));
assert_eq!(col_name(0), "A");
assert_eq!(col_name(26), "AA");
}
}
+250 -3
View File
@@ -157,6 +157,39 @@ impl Store {
content BLOB content BLOB
); );
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts); CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
CREATE TABLE IF NOT EXISTS project_approvals (
cwd TEXT NOT NULL,
key TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (cwd, key)
);
CREATE TABLE IF NOT EXISTS share_links (
token TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
owner_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_checks (
cwd TEXT PRIMARY KEY,
command TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS turn_checks (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
duration_ms INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL,
PRIMARY KEY (session_id, message_id)
);
CREATE TABLE IF NOT EXISTS session_shares ( CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -376,6 +409,16 @@ impl Store {
Ok(()) Ok(())
} }
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
)?;
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock). // Images go to the blob table (encoded before taking the lock).
let mut m = m.clone(); let mut m = m.clone();
@@ -719,6 +762,9 @@ impl Store {
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
let by_day = grouped(day_sql, "day")?; let by_day = grouped(day_sql, "day")?;
// Long ranges are shown per month.
let by_month = grouped("strftime('%Y-%m', (ts + ?3) / 1000, 'unixepoch')", "month")?;
let first_ts: Option<i64> = conn.query_row("SELECT MIN(ts) FROM usage WHERE (?1 IS NULL OR user_id = ?1)", params![user], |r| r.get(0))?;
let by_model = grouped("provider || '/' || model", "model")?; let by_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?; let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?; let by_agent = grouped("agent", "agent")?;
@@ -770,6 +816,8 @@ impl Store {
Ok(json!({ Ok(json!({
"total": total, "total": total,
"by_day": by_day, "by_day": by_day,
"by_month": by_month,
"first_ts": first_ts,
"by_day_model": by_day_model, "by_day_model": by_day_model,
"by_model": by_model, "by_model": by_model,
"by_provider": by_provider, "by_provider": by_provider,
@@ -1481,6 +1529,167 @@ impl Store {
} }
} }
// --- project overview ------------------------------------------------------------------------
impl Store {
/// Per chat: files the agent changed, the latest check result and the cost.
pub fn project_overview(&self, session_ids: &[String]) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let mut out = serde_json::Map::new();
for id in session_ids {
let files: i64 = conn.query_row("SELECT COUNT(DISTINCT path) FROM checkpoints WHERE session_id = ?1", params![id], |r| r.get(0))?;
let check: Option<String> = conn
.query_row("SELECT status FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 1", params![id], |r| r.get(0))
.optional()?;
let failed: i64 = conn.query_row("SELECT COUNT(*) FROM turn_checks WHERE session_id = ?1 AND status IN ('failed','timeout')", params![id], |r| r.get(0))?;
let cost: Option<f64> = conn.query_row("SELECT SUM(cost) FROM usage WHERE root_session_id = ?1", params![id], |r| r.get(0))?;
out.insert(id.clone(), json!({"files": files, "check": check, "checks_failed": failed, "cost": cost.unwrap_or(0.0)}));
}
Ok(Value::Object(out))
}
}
// --- weekly overview ----------------------------------------------------------------------
impl Store {
/// (chats with activity, failed checks, checks run) of a user's own top-level chats since `since` (ms).
pub fn weekly_counts(&self, user_id: &str, since: i64) -> Result<(i64, i64, i64)> {
let conn = self.conn.lock().unwrap();
let chats: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE owner_id = ?1 AND parent_id IS NULL AND updated_at >= ?2", params![user_id, since], |r| r.get(0))?;
let (failed, total): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(c.status IN ('failed','timeout')),0), COUNT(*) FROM turn_checks c JOIN sessions s ON s.id = c.session_id WHERE s.owner_id = ?1 AND c.ts >= ?2",
params![user_id, since],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((chats, failed, total))
}
}
// --- read-only links ----------------------------------------------------------------------
impl Store {
pub fn add_share_link(&self, token: &str, session_id: &str, owner_id: &str, created_at: i64, expires_at: i64) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO share_links (token, session_id, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![token, session_id, owner_id, created_at, expires_at],
)?;
Ok(())
}
/// (token, created_at, expires_at) of the links of a chat, newest first.
pub fn share_links(&self, session_id: &str) -> Result<Vec<(String, i64, i64)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT token, created_at, expires_at FROM share_links WHERE session_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([session_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// The chat a link points to while it is valid.
pub fn share_link_session(&self, token: &str, now: i64) -> Result<Option<String>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT session_id FROM share_links WHERE token = ?1 AND expires_at > ?2", params![token, now], |r| r.get(0)).optional()?)
}
pub fn delete_share_link(&self, session_id: &str, token: &str) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE session_id = ?1 AND token = ?2", params![session_id, token])?;
Ok(())
}
/// Links are dropped with their chat and when expired.
pub fn purge_share_links(&self, now: i64) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE expires_at <= ?1 OR session_id NOT IN (SELECT id FROM sessions)", params![now])?;
Ok(())
}
}
// --- defaults of new chats in a project -------------------------------------------------------
impl Store {
/// {model, agent, permission_mode, color, instructions, folder} new chats of the folder start with.
pub fn project_defaults(&self, cwd: &str) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn.query_row("SELECT data FROM project_defaults WHERE cwd = ?1", params![cwd], |r| r.get(0)).optional()?;
Ok(v.and_then(|v| serde_json::from_str(&v).ok()))
}
pub fn set_project_defaults(&self, cwd: &str, data: Option<&Value>) -> Result<()> {
let conn = self.conn.lock().unwrap();
match data {
Some(d) => conn.execute("INSERT INTO project_defaults (cwd, data) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET data = ?2", params![cwd, d.to_string()])?,
None => conn.execute("DELETE FROM project_defaults WHERE cwd = ?1", params![cwd])?,
};
Ok(())
}
}
// --- checks after a turn ------------------------------------------------------------------
impl Store {
/// (command, enabled) of a project folder.
pub fn project_check(&self, cwd: &str) -> Result<Option<(String, bool)>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT command, enabled FROM project_checks WHERE cwd = ?1", params![cwd], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? != 0))).optional()?)
}
pub fn set_project_check(&self, cwd: &str, command: &str, enabled: bool) -> Result<()> {
let conn = self.conn.lock().unwrap();
if command.trim().is_empty() {
conn.execute("DELETE FROM project_checks WHERE cwd = ?1", params![cwd])?;
} else {
conn.execute(
"INSERT INTO project_checks (cwd, command, enabled) VALUES (?1, ?2, ?3) ON CONFLICT(cwd) DO UPDATE SET command = ?2, enabled = ?3",
params![cwd, command.trim(), enabled as i64],
)?;
}
Ok(())
}
pub fn set_turn_check(&self, session_id: &str, message_id: &str, status: &str, exit_code: Option<i32>, duration_ms: u64, output: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO turn_checks (session_id, message_id, status, exit_code, duration_ms, output, ts) VALUES (?1,?2,?3,?4,?5,?6,?7)
ON CONFLICT(session_id, message_id) DO UPDATE SET status = ?3, exit_code = ?4, duration_ms = ?5, output = ?6, ts = ?7",
params![session_id, message_id, status, exit_code, duration_ms as i64, output, now_ms()],
)?;
Ok(())
}
pub fn turn_checks(&self, session_id: &str) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT message_id, status, exit_code, duration_ms, output FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 300")?;
let rows = stmt
.query_map(params![session_id], |r| {
Ok(json!({"message_id": r.get::<_, String>(0)?, "status": r.get::<_, String>(1)?, "exit_code": r.get::<_, Option<i32>>(2)?, "duration_ms": r.get::<_, i64>(3)?, "output": r.get::<_, String>(4)?}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
}
// --- project-wide approvals ---------------------------------------------------------
impl Store {
/// Approvals ("bash:<prefix>" or a tool name) remembered for a project folder.
pub fn project_approvals(&self, cwd: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT key FROM project_approvals WHERE cwd = ?1 ORDER BY key")?;
let rows = stmt.query_map(params![cwd], |r| r.get::<_, String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn add_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("INSERT OR IGNORE INTO project_approvals (cwd, key, created_at) VALUES (?1, ?2, ?3)", params![cwd, key, now_ms()])?;
Ok(())
}
pub fn delete_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM project_approvals WHERE cwd = ?1 AND key = ?2", params![cwd, key])?;
Ok(())
}
}
// --- file checkpoints ------------------------------------------------------------ // --- file checkpoints ------------------------------------------------------------
impl Store { impl Store {
@@ -1496,13 +1705,18 @@ impl Store {
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore. /// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> { pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
self.checkpoints_between(session_id, since, i64::MAX)
}
/// Per file the state before its first change in the time range [since, until).
pub fn checkpoints_between(&self, session_id: &str, since: i64, until: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 "SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)", AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![session_id, since], |r| { .query_map(params![session_id, since, until], |r| {
let existed: i64 = r.get(1)?; let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None })) Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})? })?
@@ -1572,3 +1786,36 @@ fn snippet_in(m: &Message, needle: &str) -> Option<String> {
} }
None None
} }
#[cfg(test)]
mod link_tests {
use super::*;
#[test]
fn share_links_expire_and_can_be_revoked() {
let dir = std::env::temp_dir().join(format!("cc-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
store.add_share_link("tok1", "s1", "u", 1_000, 5_000).unwrap();
store.add_share_link("tok2", "s1", "u", 2_000, 9_000).unwrap();
// Valid until the expiry time, then gone.
assert_eq!(store.share_link_session("tok1", 4_999).unwrap().as_deref(), Some("s1"));
assert_eq!(store.share_link_session("tok1", 5_000).unwrap(), None);
assert_eq!(store.share_link_session("nope", 1).unwrap(), None);
// Revoking removes only the named link of that chat.
store.delete_share_link("other", "tok2").unwrap();
assert!(store.share_link_session("tok2", 3_000).unwrap().is_some());
store.delete_share_link("s1", "tok2").unwrap();
assert_eq!(store.share_link_session("tok2", 3_000).unwrap(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn weekly_counts_of_an_empty_user_are_zero() {
let dir = std::env::temp_dir().join(format!("cc-weekly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = Store::open(&dir.join("t.db")).unwrap();
assert_eq!(store.weekly_counts("nobody", 0).unwrap(), (0, 0, 0));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+3
View File
@@ -541,6 +541,9 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
instructions: String::new(), instructions: String::new(),
// Started from Telegram: keeps reporting there after switching to another chat. // Started from Telegram: keeps reporting there after switching to another chat.
telegram_quiet: false, telegram_quiet: false,
summary: String::new(),
color: None,
icon: None,
})?; })?;
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?;
+2 -1
View File
@@ -162,7 +162,8 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
.current_dir(cwd) .current_dir(cwd)
.env("TERM", "xterm-256color") .env("TERM", "xterm-256color")
.env("CODECRAFT", "1") .env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id)); .envs(crate::gitcreds::identity_env(app, &user.id))
.envs(crate::sshkeys::env(app, user));
match crate::gitcreds::global_config(app, user) { match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c, None => c,
+1
View File
@@ -155,6 +155,7 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.env("GIT_CONFIG_GLOBAL", cfg); c.env("GIT_CONFIG_GLOBAL", cfg);
} }
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id)); c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
c.envs(crate::sshkeys::env(&ctx.app, &u));
} }
c c
} }
+34 -1
View File
@@ -7,6 +7,12 @@ self.addEventListener("push", (event) => {
data = { body: event.data ? event.data.text() : "" }; data = { body: event.data ? event.data.text() : "" };
} }
const title = data.title || "CodeCraft"; const title = data.title || "CodeCraft";
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
// Approvals can be answered right from the notification.
const actions = data.kind === "permission" && data.permission_id ? [
{ action: "allow", title: de ? "Erlauben" : "Allow" },
{ action: "reject", title: de ? "Ablehnen" : "Reject" },
] : [];
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || ""; const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
event.waitUntil( event.waitUntil(
self.registration.showNotification(prefix + title, { self.registration.showNotification(prefix + title, {
@@ -15,13 +21,40 @@ self.addEventListener("push", (event) => {
renotify: true, renotify: true,
icon: "/icon-192.png", icon: "/icon-192.png",
badge: "/icon-192.png", badge: "/icon-192.png",
data: { url: data.url || "/" }, actions,
requireInteraction: data.kind === "permission",
data: { url: data.url || "/", permission_id: data.permission_id || null },
}), }),
); );
}); });
self.addEventListener("notificationclick", (event) => { self.addEventListener("notificationclick", (event) => {
event.notification.close(); event.notification.close();
const info = event.notification.data || {};
if ((event.action === "allow" || event.action === "reject") && info.permission_id) {
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
const target = new URL(info.url || "/", self.location.origin).href;
event.waitUntil(
fetch("/api/permissions/" + encodeURIComponent(info.permission_id), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reply: event.action === "allow" ? "once" : "reject" }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
// Short confirmation, so the tap does not feel lost.
return self.registration.showNotification(event.action === "allow" ? (de ? "Erlaubt" : "Allowed") : (de ? "Abgelehnt" : "Rejected"), {
tag: "perm-" + info.permission_id,
icon: "/icon-192.png",
silent: true,
data: { url: info.url },
}).then(() => new Promise((res) => setTimeout(res, 2500))).then(() => self.registration.getNotifications({ tag: "perm-" + info.permission_id })).then((l) => l.forEach((n) => n.close()));
})
.catch(() => self.clients.openWindow(target)), // expired, answered elsewhere or signed out: open the app
);
return;
}
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href; const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
+23 -3
View File
@@ -2,8 +2,10 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n"; import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } from "./router"; import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store"; import { applyEvent, loadSessions, resync, setState, useStore } from "./store";
import type { Info } from "./types"; import type { Info } from "./types";
import { ImageViewer } from "./components/ImageViewer";
import { ProjectPage } from "./components/ProjectPage";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { Login } from "./components/Login"; import { Login } from "./components/Login";
import { ChatView } from "./components/ChatView"; import { ChatView } from "./components/ChatView";
@@ -20,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget"; import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette"; import { Palette } from "./components/Palette";
import { pendingShare } from "./share"; import { pendingShare } from "./share";
import { cacheInfo, cachedInfo, isOffline } from "./offline";
import { PublicChat } from "./components/PublicChat";
// The file manager (editor, git) is loaded on demand to keep the chat small. // The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -32,11 +36,21 @@ export function App() {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare); const [shared, setShared] = useState(pendingShare);
const offline = useStore((s) => s.offline);
useTitleActivity(); useTitleActivity();
const refresh = async () => { const refresh = async () => {
try { try {
const i = await get<Info>("/info"); let i: Info;
try {
i = await get<Info>("/info");
cacheInfo(i);
} catch (e) {
// No connection: open with the saved copy (chats are read-only).
const saved = isOffline(e) ? cachedInfo() : undefined;
if (!saved) throw e;
i = saved;
}
setState(() => ({ me: i.user, update: i.update ?? null })); setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) { if (i.user) {
await loadSessions(); await loadSessions();
@@ -69,6 +83,7 @@ export function App() {
// Close the mobile menu on navigation. // Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]); useEffect(() => setMenuOpen(false), [route.join("/")]);
if (route[0] === "shared" && route[1]) return <PublicChat token={route[1]} />;
if (error) return <div className="empty">{error}</div>; if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>; if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user) if (!info.user)
@@ -94,6 +109,7 @@ export function App() {
</div> </div>
); );
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />; else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />; else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
@@ -116,9 +132,13 @@ export function App() {
<div className="app"> <div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />} {menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} /> <Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main> <main className="main">
{offline && <div className="offline-bar" role="status">{t("offline.banner")}</div>}
{content}
</main>
<WhatsNew version={info.version} /> <WhatsNew version={info.version} />
<Palette /> <Palette />
<ImageViewer />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />} {shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost /> <DialogHost />
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
import { del, get } from "./api";
import { choiceDialog, confirmDialog, toast } from "./dialogs";
import { t } from "./i18n";
import { formatSize } from "./components/FileFigure";
interface FolderInfo {
own: boolean;
path?: string;
files?: number;
bytes?: number;
git?: boolean;
truncated?: boolean;
}
/**
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
*/
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
let withFolder = false;
if (folder?.own) {
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
const choice = await choiceDialog({
title: t("chat.deleteTitle"),
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
choices: [
{ value: "chat", label: t("chat.deleteOnlyChat") },
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
],
});
if (!choice) return false;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return false;
}
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
return true;
} catch (e: any) {
toast(e.message);
return false;
}
}
+186 -33
View File
@@ -2,11 +2,11 @@ import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { del, get, patch, post } from "../api"; import { del, get, patch, post, put } from "../api";
import { useFetch, useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store"; import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session, Todo } 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, useTodosCollapsed } from "../prefs"; import { useReasoningMode, useTodosCollapsed } from "../prefs";
@@ -24,8 +24,16 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog"; import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel"; import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { promptDialog, toast } from "../dialogs";
import { deleteChat } from "../chatActions";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions";
import { OutboxBar } from "./OutboxBar";
import { addToOutbox, isOffline } from "../outbox";
import { TurnCheck } from "./TurnCheck";
import { TurnChanges } from "./TurnChanges";
import { ChatCtx, MessageMenu } from "./MessageMenu";
import { MediaGallery } from "./MediaGallery"; import { MediaGallery } from "./MediaGallery";
type ToolResult = Extract<Part, { type: "tool_result" }>; type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -283,11 +291,28 @@ const MessageList = memo(function MessageList({
// Finished turns with tool steps fold into one line (the answer stays visible). // Finished turns with tool steps fold into one line (the answer stays visible).
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[]; const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />; const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
const prev = blocks[i - 1];
const changes = changed.size > 0 && !(busy && i === lastWork) && (
<TurnChanges
sessionId={sessionId}
fromId={b.messages[0].id}
untilId={blocks[i + 1]?.messages[0].id}
fileCount={changed.size}
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
onRewind={onRewind}
/>
);
if (i === lastWork || steps.length < 2) if (i === lastWork || steps.length < 2)
return ( return (
<div key={key}> <div key={key}>
{items} {items}
{usage} {usage}
{checkBadge}
{changes}
</div> </div>
); );
return ( return (
@@ -296,6 +321,8 @@ const MessageList = memo(function MessageList({
{items} {items}
</WorkBlock> </WorkBlock>
{usage} {usage}
{checkBadge}
{changes}
</div> </div>
); );
})} })}
@@ -348,7 +375,7 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
); );
} }
return ( return (
<div className="msg work-block" data-mid={answer?.id}> <div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}> <button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })} <Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>} {files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
@@ -357,12 +384,36 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
<> <>
<Markdown text={answerText} baseDir={baseDir} /> <Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} /> <ImageRefs text={answerText} baseDir={baseDir} />
{answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
</> </>
)} )}
</div> </div>
); );
} }
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
return (
<div className="msg-foot">
<MsgTime ms={ms} />
{menu}
</div>
);
}
/** Text parts of a message. */
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
/** Small date/time line under a message. */
function MsgTime({ ms }: { ms: number }) {
if (!ms) return null;
return (
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
{fmtMsgTime(ms)}
</time>
);
}
/** An assistant message, or a user message that only carries tool results / harness notes. */ /** An assistant message, or a user message that only carries tool results / harness notes. */
const AssistantOrNote = memo(function AssistantOrNote({ const AssistantOrNote = memo(function AssistantOrNote({
m, m,
@@ -382,8 +433,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
const cls = `msg ${highlight === m.id ? "highlight" : ""}`; const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") { if (m.role === "assistant") {
return ( return (
<div className={cls} data-mid={m.id}> <div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} /> <AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />} {regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div> </div>
); );
@@ -408,13 +460,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
return ( return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}> <details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary> <summary>
{summary.auto {summary.text.startsWith("[Context cleared")
? t("chat.cleared")
: summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" }) ? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens : summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) }) ? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")} : t("chat.compacted")}
</summary> </summary>
{summaryOpen && <Markdown text={summary.text} />} {summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
</details> </details>
); );
} }
@@ -423,18 +477,11 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
const texts = allTexts.filter((p) => !isHarnessNote(p.text)); const texts = allTexts.filter((p) => !isHarnessNote(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[]; const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
return ( return (
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}> <div className={`msg user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => ( {background.map((p, i) => (
<HarnessMessage key={i} text={p.text} /> <HarnessMessage key={i} text={p.text} />
))} ))}
<div className="msg-user"> <div className="msg-user">
{canEdit && (
<div className="msg-actions">
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button>
</div>
)}
<UserText text={texts.map((p) => p.text).join("\n\n")} /> <UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && ( {imgs.length > 0 && (
<div className="user-files"> <div className="user-files">
@@ -445,6 +492,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
))} ))}
</div> </div>
)} )}
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
</div> </div>
</div> </div>
); );
@@ -516,6 +564,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// The Telegram switch in the ⋯ menu only matters with a paired bot. // The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false; const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false); const [editingInstructions, setEditingInstructions] = useState(false);
const [showApprovals, setShowApprovals] = useState(false);
const [planning, setPlanning] = useState(false);
const models = useModels(); const models = useModels();
const [loadError, setLoadError] = useState<string>(); const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -537,11 +587,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}; };
// Scrolled away from the top: the jump buttons show up.
const [scrolled, setScrolled] = useState(false);
/** Scroll to the closest own message above the visible area (long chats on a phone). */
const jumpPrevOwn = () => {
const el = scrollRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const above = [...el.querySelectorAll<HTMLElement>(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12);
const target = above[above.length - 1];
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
else el.scrollTo({ top: 0, behavior: "smooth" });
};
// Every chat starts following. // Every chat starts following.
useEffect(() => follow(true), [sessionId]); useEffect(() => follow(true), [sessionId]);
useEffect(() => { useEffect(() => {
loadSession(sessionId).catch((e) => setLoadError(e.message)); loadSession(sessionId).catch((e) => setLoadError(e.message));
void loadChecks(sessionId);
}, [sessionId]); }, [sessionId]);
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId); const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
@@ -633,7 +697,12 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText(""); setText("");
follow(true); follow(true);
} catch (e: any) { } catch (e: any) {
toast(e.message); // No connection: keep the text on this device and send it when the server is reachable again.
if (isOffline(e) && txt && images.length === 0) {
addToOutbox(sessionId, txt, effort || undefined);
setText("");
toast(t("outbox.saved"));
} else toast(e.message);
} }
}; };
@@ -648,10 +717,48 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")); setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
}; };
/** New chats in this project start like this one (model, mode, colour, instructions, folder). */
const saveDefaults = async (remove: boolean) => {
try {
if (remove) await del(`/sessions/${sessionId}/project-defaults`);
else await put(`/sessions/${sessionId}/project-defaults`, {});
toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** The project's check command (tests, build …), run after every turn that changed files. */
const editCheck = async () => {
try {
const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`);
const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") });
if (cmd === null) return;
await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" });
toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success");
} catch (e: any) {
toast(e.message);
}
};
/** Draft an automation from this chat (small model), create it switched off and open its editor. */
const planJob = async () => {
if (planning) return;
setPlanning(true);
toast(t("job.drafting"));
try {
const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {});
const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent });
navigate(`/jobs/${job.id}`);
} catch (e: any) {
toast(e.message);
} finally {
setPlanning(false);
}
};
const remove = async () => { const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
}; };
// Context usage of the last request relative to the model's window. // Context usage of the last request relative to the model's window.
@@ -670,8 +777,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined; const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return ( return (
<> <ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
<div className="topbar"> <div className="topbar" data-color={session.color}>
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}> <button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
@@ -682,6 +789,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="undo" /> <Icon name="undo" />
</a> </a>
)} )}
{session.icon && <Icon name={session.icon} className="chat-icon" />}
{session.title || t("session.untitled")} {session.title || t("session.untitled")}
{!readOnly && ( {!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}> <button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
@@ -718,7 +826,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{!readOnly && ( {!readOnly && (
<> <>
{!session.parent_id && ( {!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}> <button className="small ghost hide-mobile" onClick={() => setSharing(true)} title={t("share.title")}>
<Icon name="share" /> <Icon name="share" />
</button> </button>
)} )}
@@ -732,11 +840,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}> <button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
<Icon name="search" /> <Icon name="search" />
</button> </button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}> <button className="small ghost hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" /> <Icon name="folder" />
</button> </button>
{!session.parent_id && ( {!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}> <button className="small ghost hide-mobile" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" /> <Icon name="fork" />
</button> </button>
)} )}
@@ -745,6 +853,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="more" /> <Icon name="more" />
</summary> </summary>
<div className="menu-pop-body"> <div className="menu-pop-body">
{/* The buttons that do not fit into the narrow bar. */}
{!session.parent_id && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}>
<Icon name="share" /> {t("share.title")}
</a>
)}
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}>
<Icon name="folder" /> {t("files.newTab")}
</a>
{!session.parent_id && !busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}>
<Icon name="fork" /> {t("chat.fork")}
</a>
)}
{!busy && (
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}>
<Icon name="compact" /> {t("command.compact")}
</a>
)}
<a href="#" className="mobile-only danger" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}>
<Icon name="trash" /> {t("chat.delete")}
</a>
<a <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -755,6 +885,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
> >
<Icon name="edit" /> {t("instructions.title")} <Icon name="edit" /> {t("instructions.title")}
</a> </a>
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
<IconPicker value={session.icon} color={session.color} onChange={(icon) => void update({ icon })} />
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
<Icon name="clock" /> {t("job.plan")}
</a>
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
<Icon name="template" /> {t("projectDefaults.save")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
<Icon name="undo" /> {t("projectDefaults.remove")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
<Icon name="check" /> {t("check.menu")}
</a>
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
<Icon name="lock" /> {t("permProject.menu")}
</a>
<a <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -804,10 +951,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
)} )}
</div> </div>
</details> </details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}> <button className="small ghost hide-mobile" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
<Icon name="compact" /> <Icon name="compact" />
</button> </button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}> <button className="small ghost danger hide-mobile" onClick={remove} title={t("chat.delete")}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</> </>
@@ -831,7 +978,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />} {searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
<ChatTabBar state={tabs} /> <ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} /> <ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}> <div className="chat-pane" data-color={session.color} data-session-id={sessionId} hidden={tabs.tab !== "chat"}>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />} {session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} /> <WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div <div
@@ -846,6 +993,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
// … scrolling back to the very end resumes it. // … scrolling back to the very end resumes it.
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true); else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight }; lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
setScrolled(el.scrollTop > 240);
}} }}
onWheel={(e) => { onWheel={(e) => {
if (e.deltaY < 0 && stick.current) follow(false); if (e.deltaY < 0 && stick.current) follow(false);
@@ -874,7 +1022,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button> </button>
)} )}
{draft && ( {draft && (
<div className="msg"> <div className={`msg ${draft.parts.some((p) => p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} /> <AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div> </div>
)} )}
@@ -925,18 +1073,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</div> </div>
)} )}
</div> </div>
{!following && ( {(!following || scrolled) && (
<div className="jump-latest"> <div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}> <button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
<Icon name="arrow-down" /> <Icon name="arrow-up" />
</button> </button>
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
<Icon name="arrow-down" />
</button>}
</div> </div>
)} )}
</div> </div>
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />} {editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />} {editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />} {sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />} {rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />} {!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? ( {readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div> <div className="readonly-bar">{t("chat.readOnlyHint")}</div>
@@ -952,7 +1105,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
/> />
)} )}
</div> </div>
</> </ChatCtx.Provider>
); );
} }
+63
View File
@@ -0,0 +1,63 @@
import { useT, type Key } from "../i18n";
import { Icon } from "./Icon";
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"] as const;
/** Icons a chat can get (must match SESSION_ICONS on the server). */
export const SESSION_ICONS = [
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
"users", "toolbox", "plug", "scroll", "eye", "template",
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
] as const;
/** Row of colour dots; the selected one is ringed, a second click removes the tag. */
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
const t = useT();
return (
<div className="color-swatches" role="radiogroup" aria-label={t("color.label")}>
{SESSION_COLORS.map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={value === c}
className={`swatch ${value === c ? "on" : ""}`}
data-color={c}
title={t(`color.${c}` as Key)}
aria-label={t(`color.${c}` as Key)}
onClick={(e) => {
e.stopPropagation();
onChange(value === c ? "" : c);
}}
/>
))}
</div>
);
}
/** Grid of icons for a chat (in the colour of the chat); a second click removes the icon. */
export function IconPicker({ value, color, onChange }: { value?: string; color?: string; onChange: (icon: string) => void }) {
const t = useT();
return (
<div className="icon-picker" role="radiogroup" aria-label={t("icon.label")} data-color={color}>
{SESSION_ICONS.map((i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={value === i}
className={`icon-choice ${value === i ? "on" : ""}`}
title={i}
aria-label={i}
onClick={(e) => {
e.stopPropagation();
onChange(value === i ? "" : i);
}}
>
<Icon name={i} />
</button>
))}
</div>
);
}
+30 -3
View File
@@ -10,6 +10,7 @@ import { ModelSelect } from "./ModelSelect";
import { EffortSelect } from "./Regenerate"; import { EffortSelect } from "./Regenerate";
import { VoiceButton } from "./Voice"; import { VoiceButton } from "./Voice";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import { COMPOSE_EVENT, type ComposeDetail } from "../compose";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion { interface Suggestion {
@@ -19,6 +20,7 @@ interface Suggestion {
const BUILTIN: { name: string; key: Key }[] = [ const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" }, { name: "compact", key: "command.compact" },
{ name: "clear", key: "command.clear" },
{ name: "new", key: "command.new" }, { name: "new", key: "command.new" },
]; ];
@@ -77,6 +79,7 @@ export function Composer({
const [mention, setMention] = useState<{ start: number; end: number; q: string }>(); const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]); const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary"); const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const snippets = useFetch<{ id: string; name: string; text: string }[]>("/account/snippets").data;
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data; const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow. // Auto-grow.
@@ -133,11 +136,14 @@ export function Composer({
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })), ...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []), ...(cmdData?.commands ?? []),
]; ];
return all const mine = (snippets ?? [])
.filter((s) => s.name.toLowerCase().startsWith(m[1].toLowerCase()))
.map((s) => ({ key: `snip:${s.id}`, label: `/${s.name}`, description: s.text.replace(/\s+/g, " ").slice(0, 80), apply: () => (setText(s.text), requestAnimationFrame(() => ref.current?.focus())) }));
const cmds = all
.filter((c) => c.name.startsWith(m[1])) .filter((c) => c.name.startsWith(m[1]))
.slice(0, 12)
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) })); .map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
}, [text, cmdData, t, mention, mentionHits]); return [...mine, ...cmds].slice(0, 12);
}, [text, cmdData, snippets, t, mention, mentionHits]);
const send = () => { const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return; if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
@@ -207,6 +213,27 @@ export function Composer({
} }
}; };
// Content sent from elsewhere (quote a message, image viewer): latest closures via refs.
const live = useRef({ text, addFiles, setText });
live.current = { text, addFiles, setText };
useEffect(() => {
const on = (e: Event) => {
const d = (e as CustomEvent<ComposeDetail>).detail;
if (d.sessionId !== session.id) return;
if (d.text) live.current.setText(live.current.text.trim() ? `${live.current.text.replace(/\s+$/, "")}\n\n${d.text}` : d.text);
if (d.files?.length) void live.current.addFiles(d.files);
requestAnimationFrame(() => {
const el = ref.current;
if (!el) return;
setPreview(false);
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
};
window.addEventListener(COMPOSE_EVENT, on);
return () => window.removeEventListener(COMPOSE_EVENT, on);
}, [session.id]);
const removeFile = (path: string) => { const removeFile = (path: string) => {
setFiles((cur) => cur.filter((f) => f.path !== path)); setFiles((cur) => cur.filter((f) => f.path !== path));
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" }); void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
+7
View File
@@ -207,6 +207,13 @@ export function GitPanel({
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")} <input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
</label> </label>
<span className="spacer" /> <span className="spacer" />
<button
disabled={!!busy || staged.length === 0}
title={t("git.suggestHint")}
onClick={() => run("suggest", async () => setMessage((await call<{ message: string }>("suggest")).message))}
>
{busy === "suggest" ? <Spinner small /> : t("git.suggest")}
</button>
<button <button
className="primary" className="primary"
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))} disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
+92
View File
@@ -34,6 +34,98 @@ const P: Record<string, ReactNode> = {
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />, download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />, compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />, trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
gamepad: (
<>
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
</>
),
computer: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</>
),
food: (
<>
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
</>
),
home: (
<>
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
<path d="M10 20v-5h4v5" />
</>
),
book: (
<>
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
<path d="M5 17a3 3 0 0 1 3-3h11" />
</>
),
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
camera: (
<>
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
<circle cx="12" cy="13" r="3.5" />
</>
),
cart: (
<>
<path d="M3 4h2.5l2 11h10l2-8H7" />
<circle cx="9" cy="19" r="1.4" />
<circle cx="17" cy="19" r="1.4" />
</>
),
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
globe: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
</>
),
calendar: (
<>
<rect x="4" y="5" width="16" height="15" rx="2" />
<path d="M4 10h16M8 3v4M16 3v4" />
</>
),
coffee: (
<>
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
</>
),
car: (
<>
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
<path d="M3.5 12h17" />
<circle cx="7.5" cy="16" r="1.5" />
<circle cx="16.5" cy="16" r="1.5" />
</>
),
plane: (
<>
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
<path d="M9.5 13 21 3" />
</>
),
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
money: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
</>
),
palette: (
<>
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
</>
),
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />, edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />, undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />, clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
type Tool = "pen" | "arrow" | "rect";
interface Shape {
tool: Tool;
color: string;
/** pen: all points; arrow/rect: start and end. */
pts: { x: number; y: number }[];
}
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
const MAX_SIDE = 2000;
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
const lw = Math.max(3, Math.round(w / 280));
for (const s of shapes) {
ctx.strokeStyle = s.color;
ctx.fillStyle = s.color;
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const a = s.pts[0];
const b = s.pts[s.pts.length - 1];
ctx.beginPath();
if (s.tool === "pen") {
ctx.moveTo(a.x, a.y);
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
ctx.stroke();
} else if (s.tool === "rect") {
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
} else {
// Arrow: line with a filled head at the end.
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const head = lw * 5;
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
ctx.closePath();
ctx.fill();
}
}
}
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
const t = useT();
const canvas = useRef<HTMLCanvasElement>(null);
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
const [shapes, setShapes] = useState<Shape[]>([]);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState(COLORS[0]);
const [ready, setReady] = useState(false);
const drawing = useRef<Shape | null>(null);
const redraw = (extra?: Shape | null) => {
const c = canvas.current;
const img = bitmap.current;
if (!c || !img) return;
const ctx = c.getContext("2d")!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
};
useEffect(() => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const c = canvas.current;
if (!c) return;
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
bitmap.current = img;
setReady(true);
};
img.onerror = () => {
toast(t("viewer.annotateFailed"));
onCancel();
};
img.src = src;
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
const point = (e: React.PointerEvent) => {
const c = canvas.current!;
const r = c.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
};
const finish = () => {
const c = canvas.current;
if (!c) return;
c.toBlob((blob) => {
if (!blob) return toast(t("viewer.annotateFailed"));
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
}, "image/png");
};
return (
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
<canvas
ref={canvas}
style={{ visibility: ready ? "visible" : "hidden" }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drawing.current = { tool, color, pts: [point(e)] };
}}
onPointerMove={(e) => {
const d = drawing.current;
if (!d) return;
const p = point(e);
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
redraw(d);
}}
onPointerUp={() => {
const d = drawing.current;
drawing.current = null;
if (d) setShapes((s) => [...s, d]);
}}
onPointerCancel={() => {
drawing.current = null;
redraw();
}}
/>
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
</button>
))}
<span className="annotator-colors">
{COLORS.map((c) => (
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
))}
</span>
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
<Icon name="undo" />
</button>
<span className="grow" />
<button className="small" onClick={onCancel}>
{t("common.cancel")}
</button>
<button className="small primary" onClick={finish} disabled={!ready}>
<Icon name="clip" /> {t("viewer.attach")}
</button>
</div>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "./Icon";
import { ImageAnnotator } from "./ImageAnnotator";
import { compose } from "../compose";
import { toast } from "../dialogs";
import { useT } from "../i18n";
/** Images that open in the viewer: everything in the app, except UI chrome. */
const SKIP = ".viewer-overlay, .sidebar, .topbar, button, .modal-backdrop .viewer, .model-viewer, .media-stage, .composer, .brand, .login";
interface Item {
src: string;
alt: string;
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
/**
* Popup image viewer: a click on any image of the app opens it fullscreen.
* Pinch / wheel / double tap to zoom, drag to pan, arrows or swipe for the other
* images of the same chat, Esc to close. Mounted once; listens for clicks globally.
*/
export function ImageViewer() {
const t = useT();
const [list, setList] = useState<Item[]>([]);
const [idx, setIdx] = useState(0);
const [open, setOpen] = useState(false);
// Chat the image belongs to (for "use in message"); drawing mode.
const [sessionId, setSessionId] = useState<string>();
const [marking, setMarking] = useState(false);
const [view, setView] = useState({ x: 0, y: 0, s: 1 });
const pointers = useRef(new Map<number, { x: number; y: number }>());
const gesture = useRef<{ d: number; s: number } | null>(null);
const moved = useRef(false);
const lastTap = useRef(0);
const swipe = useRef<{ x: number; y: number } | null>(null);
const reset = useCallback(() => setView({ x: 0, y: 0, s: 1 }), []);
const close = useCallback(() => setOpen(false), []);
const go = useCallback(
(d: number) => {
setIdx((i) => clamp(i + d, 0, list.length - 1));
reset();
},
[list.length, reset],
);
useEffect(() => {
const onClick = (e: MouseEvent) => {
const img = (e.target as HTMLElement | null)?.closest?.("img");
if (!img || img.closest(SKIP) || e.defaultPrevented || e.button !== 0) return;
if (img.naturalWidth && img.naturalWidth < 24) return; // icons
e.preventDefault(); // the surrounding link would open a new tab
e.stopPropagation();
const scope = img.closest(".chat, .files-main, .gallery, .main") ?? document;
const all = [...scope.querySelectorAll("img")].filter((i) => !i.closest(SKIP) && i.src && (i.naturalWidth === 0 || i.naturalWidth >= 24));
const seen = new Set<string>();
const items: Item[] = [];
let start = 0;
for (const i of all) {
if (seen.has(i.currentSrc || i.src)) {
if (i === img) start = items.findIndex((x) => x.src === (i.currentSrc || i.src));
continue;
}
seen.add(i.currentSrc || i.src);
if (i === img) start = items.length;
items.push({ src: i.currentSrc || i.src, alt: i.alt });
}
setSessionId((img.closest("[data-session-id]") as HTMLElement | null)?.dataset.sessionId);
setMarking(false);
setList(items.length ? items : [{ src: img.currentSrc || img.src, alt: img.alt }]);
setIdx(Math.max(0, start));
reset();
setOpen(true);
};
// Capture phase: before the link around a thumbnail navigates.
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, [reset]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (marking) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowLeft") go(-1);
else if (e.key === "ArrowRight") go(1);
else if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, s: clamp(v.s * 1.25, 1, 8) }));
else if (e.key === "-") setView((v) => ({ ...v, s: clamp(v.s / 1.25, 1, 8), ...(v.s / 1.25 <= 1 ? { x: 0, y: 0 } : {}) }));
else return;
e.preventDefault();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, close, go, marking]);
if (!open || !list[idx]) return null;
const cur = list[idx];
/** The image as a file for the composer (variants, questions about it, …). */
async function useInChat() {
try {
const blob = await (await fetch(cur.src, { credentials: "same-origin" })).blob();
const ext = blob.type.split("/")[1]?.split("+")[0] || "png";
compose({ sessionId: sessionId!, files: [new File([blob], `image.${ext}`, { type: blob.type || "image/png" })] });
toast(t("viewer.attached"), "success");
close();
} catch {
toast(t("viewer.annotateFailed"));
}
}
const dist = () => {
const [a, b] = [...pointers.current.values()];
return Math.hypot(a.x - b.x, a.y - b.y);
};
return (
<div
className="viewer-overlay"
role="dialog"
aria-modal="true"
aria-label={t("viewer.title")}
onClick={() => {
if (!moved.current) close();
}}
onWheel={(e) => setView((v) => {
const s = clamp(v.s * (e.deltaY < 0 ? 1.15 : 1 / 1.15), 1, 8);
return s === 1 ? { x: 0, y: 0, s } : { ...v, s };
})}
>
<div className="viewer-bar" onClick={(e) => e.stopPropagation()}>
<span className="viewer-count">{list.length > 1 ? `${idx + 1} / ${list.length}` : ""}</span>
<span className="viewer-name">{cur.alt && !/^(image|SVG)$/i.test(cur.alt) ? cur.alt : ""}</span>
{sessionId && (
<>
<button className="small ghost" onClick={() => setMarking(true)} title={t("viewer.annotate")} aria-label={t("viewer.annotate")}>
<Icon name="edit" />
</button>
<button className="small ghost" onClick={() => void useInChat()} title={t("viewer.useInChat")} aria-label={t("viewer.useInChat")}>
<Icon name="reply" />
</button>
</>
)}
<a className="button small ghost" href={cur.src} target="_blank" rel="noreferrer" title={t("viewer.open")} aria-label={t("viewer.open")}>
<Icon name="share" />
</a>
<a className="button small ghost" href={cur.src} download title={t("viewer.download")} aria-label={t("viewer.download")}>
<Icon name="download" />
</a>
<button className="small ghost" onClick={close} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{marking && (
<ImageAnnotator
src={cur.src}
name={cur.alt || "image"}
onCancel={() => setMarking(false)}
onDone={(file) => {
compose({ sessionId: sessionId!, files: [file] });
toast(t("viewer.attached"), "success");
close();
}}
/>
)}
{!marking && idx > 0 && (
<button className="viewer-nav prev" onClick={(e) => (e.stopPropagation(), go(-1))} aria-label={t("media.prev")}>
<Icon name="arrow-left" />
</button>
)}
{!marking && idx < list.length - 1 && (
<button className="viewer-nav next" onClick={(e) => (e.stopPropagation(), go(1))} aria-label={t("media.next")}>
<Icon name="arrow-right" />
</button>
)}
{!marking && <img
key={cur.src}
className="viewer-img"
src={cur.src}
alt={cur.alt}
draggable={false}
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, cursor: view.s > 1 ? "grab" : "zoom-in" }}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
moved.current = false;
if (pointers.current.size === 2) gesture.current = { d: dist(), s: view.s };
else swipe.current = { x: e.clientX, y: e.clientY };
}}
onPointerMove={(e) => {
const p = pointers.current.get(e.pointerId);
if (!p) return;
const dx = e.clientX - p.x;
const dy = e.clientY - p.y;
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.current.size === 2 && gesture.current) {
moved.current = true;
const s = clamp((gesture.current.s * dist()) / gesture.current.d, 1, 8);
setView((v) => (s === 1 ? { x: 0, y: 0, s } : { ...v, s }));
} else if (view.s > 1) {
if (Math.abs(dx) + Math.abs(dy) > 1) moved.current = true;
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
}
}}
onPointerUp={(e) => {
pointers.current.delete(e.pointerId);
gesture.current = null;
const s0 = swipe.current;
swipe.current = null;
if (s0 && view.s === 1) {
const dx = e.clientX - s0.x;
const dy = e.clientY - s0.y;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
moved.current = true;
go(dx < 0 ? 1 : -1);
} else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.5) {
moved.current = true;
close();
}
}
// Double tap / double click: zoom in or back.
if (!moved.current) {
const now = Date.now();
if (now - lastTap.current < 300) setView((v) => (v.s > 1 ? { x: 0, y: 0, s: 1 } : { ...v, s: 2.5 }));
lastTap.current = now;
}
}}
onPointerCancel={(e) => {
pointers.current.delete(e.pointerId);
gesture.current = null;
}}
/>}
</div>
);
}
+38 -2
View File
@@ -1,7 +1,7 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useState } from "react"; import { useState } from "react";
import { post } from "../api"; import { post } from "../api";
import { useT } from "../i18n"; import { getLang, useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
import type { PermissionRequest, QuestionRequest } from "../types"; import type { PermissionRequest, QuestionRequest } from "../types";
@@ -31,7 +31,24 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
const chosen = selected.filter(Boolean).length; const chosen = selected.filter(Boolean).length;
const partial = hunkCount > 1 && chosen < hunkCount; const partial = hunkCount > 1 && chosen < hunkCount;
const reply = async (r: "once" | "always" | "reject") => { const [explaining, setExplaining] = useState(false);
const [explanation, setExplanation] = useState<string>();
const [explainError, setExplainError] = useState<string>();
/** The small model explains in plain words what the action does (approving on a phone without reading code). */
const explain = async () => {
setExplaining(true);
setExplainError(undefined);
try {
const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() });
setExplanation(r.text);
} catch (e: any) {
setExplainError(e.message);
} finally {
setExplaining(false);
}
};
const reply = async (r: "once" | "always" | "project" | "reject") => {
setBusy(true); setBusy(true);
try { try {
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined; const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
@@ -47,6 +64,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) ? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
: t("permission.alwaysTool", { tool: req.tool }); : t("permission.alwaysTool", { tool: req.tool });
// Dangerous commands are only ever allowed for the session (the server decides; this hides the button).
const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command);
const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool });
return ( return (
<div className="card" role="alertdialog" aria-label={t("permission.title")}> <div className="card" role="alertdialog" aria-label={t("permission.title")}>
<h3> <h3>
@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
</details> </details>
) )
)} )}
{explanation ? (
<div className="explain" role="note">
<Icon name="sparkle" className="tiny" /> {explanation}
</div>
) : (
<button className="small ghost explain-btn" disabled={explaining} onClick={explain}>
<Icon name="sparkle" /> {explaining ? t("permission.explaining") : t("permission.explain")}
</button>
)}
{explainError && <div className="hint">{explainError}</div>}
<input <input
style={{ width: "100%", marginTop: 8 }} style={{ width: "100%", marginTop: 8 }}
placeholder={t("permission.feedback")} placeholder={t("permission.feedback")}
@@ -86,6 +117,11 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
<button disabled={busy || partial} onClick={() => reply("always")}> <button disabled={busy || partial} onClick={() => reply("always")}>
{alwaysLabel} {alwaysLabel}
</button> </button>
{!dangerous && (
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
{projectLabel}
</button>
)}
<button className="danger" disabled={busy} onClick={() => reply("reject")}> <button className="danger" disabled={busy} onClick={() => reply("reject")}>
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")} {feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
</button> </button>
+6
View File
@@ -20,6 +20,7 @@ interface Settings {
model: string; model: string;
permission_mode: string; permission_mode: string;
timeout_minutes: number; timeout_minutes: number;
notify: boolean;
} }
const settingsOf = (j: JobInfo): Settings => ({ const settingsOf = (j: JobInfo): Settings => ({
@@ -29,6 +30,7 @@ const settingsOf = (j: JobInfo): Settings => ({
model: j.spec?.model ?? "", model: j.spec?.model ?? "",
permission_mode: j.spec?.permission_mode ?? "auto", permission_mode: j.spec?.permission_mode ?? "auto",
timeout_minutes: j.spec?.timeout_minutes ?? 60, timeout_minutes: j.spec?.timeout_minutes ?? 60,
notify: j.spec?.notify ?? true,
}); });
type Tab = "prompt" | "settings" | "assistant"; type Tab = "prompt" | "settings" | "assistant";
@@ -254,6 +256,10 @@ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} /> <input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
</label> </label>
</div> </div>
<label className="row" style={{ gap: 6, alignItems: "center", marginTop: 6 }}>
<input type="checkbox" checked={settings.notify} onChange={(e) => up({ notify: e.target.checked })} />
{t("jobEditor.notify")}
</label>
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>} {settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section> </section>
<section className="job-editor-prompt"> <section className="job-editor-prompt">
+1 -1
View File
@@ -17,7 +17,7 @@ export interface JobInfo {
next_run?: number | null; next_run?: number | null;
error?: string; error?: string;
webhook?: boolean; webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null }; spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null };
} }
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"); const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
+75
View File
@@ -0,0 +1,75 @@
import { createContext, useContext } from "react";
import { post } from "../api";
import { asQuote, compose } from "../compose";
import { useT } from "../i18n";
import { navigate } from "../router";
import { loadSessions } from "../store";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
/** What the message menus of one chat need to know. */
export const ChatCtx = createContext<{ sessionId: string; canFork: boolean; canReply: boolean }>({ sessionId: "", canFork: false, canReply: false });
/**
* Actions of one message: copy, quote into the reply, fork the chat from here and
* (for own messages) edit and resend. Hover on desktop, always faintly visible on touch.
*/
export function MessageMenu({ messageId, text, forkable, onRewind }: { messageId: string; text: string; forkable: boolean; onRewind?: () => void }) {
const t = useT();
const { sessionId, canFork, canReply } = useContext(ChatCtx);
const close = (e: React.MouseEvent) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
};
if (!text.trim() && !onRewind) return null;
return (
<details className="menu-pop msg-menu">
<summary className="small ghost" title={t("msg.actions")} aria-label={t("msg.actions")}>
<Icon name="more" />
</summary>
<div className="menu-pop-body">
{text.trim() && (
<a
href="#"
onClick={(e) => {
close(e);
const done = () => toast(t("msg.copied"), "success");
if (navigator.clipboard?.writeText) void navigator.clipboard.writeText(text).then(done, () => toast(t("msg.copyFailed")));
else toast(t("msg.copyFailed"));
}}
>
<Icon name="copy" /> {t("msg.copy")}
</a>
)}
{text.trim() && canReply && (
<a href="#" onClick={(e) => (close(e), compose({ sessionId, text: asQuote(text) }))}>
<Icon name="reply" /> {t("msg.quote")}
</a>
)}
{onRewind && (
<a href="#" onClick={(e) => (close(e), onRewind())}>
<Icon name="edit" /> {t("chat.rewind")}
</a>
)}
{forkable && canFork && (
<a
href="#"
onClick={async (e) => {
close(e);
try {
const fork = await post<{ id: string }>(`/sessions/${sessionId}/fork`, { message_id: messageId });
await loadSessions();
toast(t("chat.forked"));
navigate(`/s/${fork.id}`);
} catch (err: any) {
toast(err.message);
}
}}
>
<Icon name="fork" /> {t("msg.forkHere")}
</a>
)}
</div>
</details>
);
}
+2 -1
View File
@@ -328,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const isCraft = task === "craft"; const isCraft = task === "craft";
const s = await post<{ id: string }>("/sessions", { const s = await post<{ id: string }>("/sessions", {
cwd, cwd,
model: model || undefined, // The server default is not sent, so the project's own default model can apply.
model: model && model !== config?.default_model ? model : undefined,
agent: task === "cad" ? "build" : task, agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined, clean_room: isCraft ? !deep : undefined,
}); });
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { flushOutbox, removeFromOutbox, useOutbox } from "../outbox";
import { useStore } from "../store";
import { Icon } from "./Icon";
/** Messages waiting for a connection: sent automatically once the server is reachable again. */
export function OutboxBar({ sessionId }: { sessionId: string }) {
const t = useT();
const items = useOutbox(sessionId);
const connected = useStore((s) => s.connected);
useEffect(() => {
if (!items.length) return;
const go = () => void flushOutbox(sessionId);
if (connected && navigator.onLine) go();
window.addEventListener("online", go);
return () => window.removeEventListener("online", go);
}, [sessionId, connected, items.length]);
if (!items.length) return null;
return (
<div className="outbox" role="status">
<div className="outbox-head">
<Icon name="clock" className="tiny" /> {t("outbox.waiting", { n: items.length })}
<span className="grow" />
<button className="small" onClick={() => void flushOutbox(sessionId)}>
{t("outbox.sendNow")}
</button>
</div>
{items.map((i) => (
<div key={i.id} className="outbox-item">
<span>{i.text.length > 120 ? `${i.text.slice(0, 120)}…` : i.text}</span>
<button className="small ghost" onClick={() => removeFromOutbox(i.id)} aria-label={t("outbox.discard")} title={t("outbox.discard")}>
<Icon name="close" />
</button>
</div>
))}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import { useFetch } from "../hooks";
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import type { Session } from "../types";
import { Icon } from "./Icon";
import { basename } from "./FileFigure";
interface Overview {
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
check: { command: string; enabled: boolean } | null;
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
currency: string;
}
const day = (ms: number) => new Date(ms).toDateString();
/** One project folder: its chats as a timeline (newest first) with what each one did. */
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
const t = useT();
const all = useStore((s) => s.sessions);
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
// Day groups: "Today", "Yesterday", date.
const groups = useMemo(() => {
const out: { label: string; items: Session[] }[] = [];
const today = day(Date.now());
const yesterday = day(Date.now() - 86_400_000);
for (const s of sessions) {
const d = day(s.updated_at);
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(s);
else out.push({ label, items: [s] });
}
return out;
}, [sessions, t]);
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
const newChat = () => {
try {
localStorage.setItem("codecraft.lastDir", cwd);
} catch {
// Without storage the wizard starts at its usual folder.
}
navigate("/new");
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>
<Icon name="folder" /> {basename(cwd) || "/"}
</h1>
<div className="path mono">{cwd}</div>
</div>
<button className="small primary" onClick={newChat}>
<Icon name="plus" /> {t("project.newChat")}
</button>
</div>
<div className="chat-scroll">
<div className="chat project-page">
<div className="project-summary">
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
{data?.check?.enabled && (
<span className="chip mono" title={t("check.title")}>
<Icon name="check" className="tiny" /> {data.check.command}
</span>
)}
{data?.defaults?.model && (
<span className="chip" title={t("projectDefaults.hint")}>
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
</span>
)}
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
</div>
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
{groups.map((g) => (
<section key={g.label} className="timeline-day">
<h3>{g.label}</h3>
<ol className="timeline">
{g.items.map((s) => {
const info = data?.sessions[s.id];
return (
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
<div className="tl-title">
<span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
<span className="tl-when" title={fmtDate(s.updated_at)}>
{fmtRelative(s.updated_at)}
</span>
</div>
{s.summary && <div className="tl-summary">{s.summary}</div>}
<div className="tl-meta">
<span>{s.agent}</span>
<span>{s.model.split("/").pop()}</span>
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
{info?.check && (
<span className={`check-${info.check}`}>
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
</span>
)}
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
</div>
</a>
</li>
);
})}
</ol>
</section>
))}
</div>
</div>
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { api, get } from "../api";
import { useT } from "../i18n";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
/** "bash:git status" → "git status"; tool names stay. */
const label = (key: string) => (key.startsWith("bash:") ? `$ ${key.slice(5)}` : key);
/** What the user allowed "for this project" (all chats of the folder) – with a way to revoke it. */
export function ProjectPermissions({ sessionId, cwd, onClose }: { sessionId: string; cwd: string; onClose: () => void }) {
const t = useT();
const [keys, setKeys] = useState<string[]>();
useEffect(() => {
get<string[]>(`/sessions/${sessionId}/approvals`).then(setKeys, (e) => (toast(e.message), setKeys([])));
}, [sessionId]);
const revoke = async (key: string) => {
try {
await api(`/sessions/${sessionId}/approvals`, { method: "DELETE", json: { key } });
setKeys((k) => k?.filter((x) => x !== key));
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("permProject.title")} onClick={(e) => e.stopPropagation()}>
<h3>
<Icon name="lock" /> {t("permProject.title")}
</h3>
<p style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 4 }}>{t("permProject.intro")}</p>
<div className="path mono" style={{ marginBottom: 8 }}>{cwd}</div>
{keys === undefined ? (
<div className="empty">…</div>
) : keys.length === 0 ? (
<p style={{ fontSize: 13 }}>{t("permProject.empty")}</p>
) : (
<ul className="rewind-files">
{keys.map((k) => (
<li key={k} className="row" style={{ justifyContent: "space-between" }}>
<code>{label(k)}</code>
<button className="small danger" onClick={() => void revoke(k)}>
{t("permProject.revoke")}
</button>
</li>
))}
</ul>
)}
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { fmtMsgTime, useT } from "../i18n";
import { Markdown } from "./Markdown";
interface Shared {
title: string;
messages: { id: string; role: "user" | "assistant"; text: string; created_at: number }[];
}
/** A chat opened through a read-only link (no account needed). */
export function PublicChat({ token }: { token: string }) {
const t = useT();
const [chat, setChat] = useState<Shared>();
const [error, setError] = useState<string>();
useEffect(() => {
get<Shared>(`/public/chat/${encodeURIComponent(token)}`)
.then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`)))
.catch((e) => setError(e.message));
}, [token]);
if (error) return <div className="empty">{error}</div>;
if (!chat) return <div className="empty">{t("common.loading")}</div>;
return (
<div className="public-chat">
<header>
<h1>{chat.title || "Chat"}</h1>
<div className="hint">{t("link.readOnly")}</div>
</header>
<div className="chat-scroll">
{chat.messages.map((m) => (
<div key={m.id} className={m.role === "user" ? "bubble user-msg" : "bubble agent"}>
<Markdown text={m.text} />
<div className="msg-foot">
<span className="hint">{fmtMsgTime(m.created_at)}</span>
</div>
</div>
))}
</div>
</div>
);
}
+38 -1
View File
@@ -146,8 +146,11 @@ function VersionInfo() {
function DigestToggle() { function DigestToggle() {
const t = useT(); const t = useT();
const [on, setOn] = useState<boolean>(); const [on, setOn] = useState<boolean>();
const [weekly, setWeekly] = useState(false);
useEffect(() => { useEffect(() => {
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {}); get<{ enabled: boolean; weekly?: boolean }>("/push/digest")
.then((r) => (setOn(r.enabled), setWeekly(!!r.weekly)))
.catch(() => {});
}, []); }, []);
if (on === undefined) return null; if (on === undefined) return null;
return ( return (
@@ -164,6 +167,30 @@ function DigestToggle() {
/> />
{t("settings.digest")} {t("settings.digest")}
</label> </label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={weekly}
onChange={(e) => {
const v = e.target.checked;
setWeekly(v);
put("/push/digest", { weekly: v }).catch((err) => (setWeekly(!v), toast(err.message)));
}}
/>
{t("settings.weeklyDigest")}
</label>
{weekly && (
<button
className="small ghost"
onClick={() =>
post<{ sent: number; errors: string[] }>("/push/digest?weekly=true")
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
.catch((e) => toast(e.message))
}
>
{t("settings.digestNow")}
</button>
)}
{on && ( {on && (
<button <button
className="small ghost" className="small ghost"
@@ -246,6 +273,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
default_model: s.default_model ?? "", default_model: s.default_model ?? "",
small_model: s.small_model ?? "", small_model: s.small_model ?? "",
plan_model: ((cfg.config as any).agents?.plan?.model ?? "") as string,
transcribe_model: ((s as any).transcribe_model ?? "") as string,
currency: s.currency ?? "USD", currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85, auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string, commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
@@ -270,6 +299,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
savePatches([ savePatches([
{ path: ["server", "default_model"], value: form.default_model }, { path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_model }, { path: ["server", "small_model"], value: form.small_model },
{ path: ["agents", "plan", "model"], value: form.plan_model || null },
{ path: ["server", "transcribe_model"], value: form.transcribe_model.trim() },
{ path: ["server", "currency"], value: form.currency }, { path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) }, { path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() }, { path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
@@ -302,6 +333,12 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.smallModel")}</label> <label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} /> <ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div> <div className="hint">{t("settings.smallModelHint")}</div>
<label>{t("settings.planModel")}</label>
<ModelSelect value={form.plan_model} emptyLabel={t("settings.sameAsChat")} onChange={(v) => setForm({ ...form, plan_model: v })} />
<div className="hint">{t("settings.planModelHint")}</div>
<label>{t("settings.transcribeModel")}</label>
<input value={form.transcribe_model} placeholder="openai/whisper-1" onChange={(e) => setForm({ ...form, transcribe_model: e.target.value })} />
<div className="hint">{t("settings.transcribeHint")}</div>
<label>{t("settings.currency")}</label> <label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} /> <input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label> <label>{t("settings.autoCompact")}</label>
+52 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { get, put } from "../api"; import { del, get, post, put } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
import { toast } from "../dialogs";
import { fmtDate } from "../i18n";
interface DirUser { interface DirUser {
id: string; id: string;
@@ -9,6 +11,54 @@ interface DirUser {
display_name: string; display_name: string;
} }
interface Link {
token: string;
url: string;
expires_at: number;
}
/** Read-only links for people without an account. */
function Links({ sessionId }: { sessionId: string }) {
const t = useT();
const [links, setLinks] = useState<Link[]>([]);
const [days, setDays] = useState(7);
const load = () => get<Link[]>(`/sessions/${sessionId}/links`).then(setLinks).catch(() => undefined);
useEffect(() => void load(), [sessionId]);
const create = async () => {
try {
const l = await post<Link>(`/sessions/${sessionId}/links`, { days });
await navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"), () => undefined);
void load();
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="share-links">
<h4>{t("link.title")}</h4>
<div className="hint">{t("link.hint")}</div>
{links.map((l) => (
<div key={l.token} className="row share-link">
<input readOnly value={l.url} onFocus={(e) => e.currentTarget.select()} aria-label={t("link.title")} />
<span className="hint">{t("link.until", { date: fmtDate(l.expires_at, false) })}</span>
<button onClick={() => navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"))}>{t("link.copy")}</button>
<button onClick={() => del(`/sessions/${sessionId}/links/${l.token}`).then(load).catch((e) => toast(e.message))}>{t("link.revoke")}</button>
</div>
))}
<div className="row" style={{ marginTop: 6 }}>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} aria-label={t("link.validity")}>
{[1, 7, 30, 90].map((d) => (
<option key={d} value={d}>
{t("link.days", { n: d })}
</option>
))}
</select>
<button onClick={create}>{t("link.create")}</button>
</div>
</div>
);
}
/** Choose users a session is shared with (read-only for them). */ /** Choose users a session is shared with (read-only for them). */
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) { export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT(); const t = useT();
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
</label> </label>
))} ))}
</div> </div>
<Links sessionId={sessionId} />
{error && <div className="notice err">{error}</div>} {error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}> <div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button> <button onClick={onClose}>{t("common.cancel")}</button>
+25 -3
View File
@@ -1,3 +1,4 @@
import { deleteChat } from "../chatActions";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -25,7 +26,7 @@ function groupKey(ms: number): Key {
const GROUP_BY = "codecraft.sidebarGroupBy"; const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed"; const COLLAPSED = "codecraft.sidebarCollapsed";
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string; icon?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
/** One session in the list, with a menu to pin, file or archive it. */ /** One session in the list, with a menu to pin, file or archive it. */
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) { function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
@@ -53,11 +54,15 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
}; };
return ( return (
<div className={`session-row ${menu ? "menu-open" : ""}`}> <div className={`session-row ${menu ? "menu-open" : ""}`}>
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}> <button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
<div className="title"> <div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />} {attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
<span>{s.title || t("session.untitled")}</span> <span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
</div> </div>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
<div className="sub"> <div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>} {s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)} {basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
@@ -96,6 +101,18 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}> <button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")} <Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button> </button>
{!s.shared && (
<button
role="menuitem"
className="danger"
onClick={async () => {
setMenu(false);
if ((await deleteChat(s)) && active) navigate("/new");
}}
>
<Icon name="trash" /> {t("chat.delete")}
</button>
)}
</div> </div>
)} )}
</div> </div>
@@ -194,6 +211,11 @@ function GroupHeader({
)} )}
{menu && ( {menu && (
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}> <div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
{g.id.startsWith("project:") && (
<button role="menuitem" onClick={() => (setMenu(false), navigate(`/p/${encodeURIComponent(g.id.slice("project:".length))}`))}>
<Icon name="clock" /> {t("project.open")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}> <button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")} <Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
</button> </button>
+185 -55
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { useMemo, useState } from "react"; import { Fragment, useEffect, useMemo, useState } from "react";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
@@ -15,6 +15,9 @@ interface StatsData {
total: Aggregate; total: Aggregate;
by_day: Row<"day">[]; by_day: Row<"day">[];
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[]; by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
by_month: Row<"month">[];
/** First recorded request (any range): the years to choose from. */
first_ts: number | null;
by_model: Row<"model">[]; by_model: Row<"model">[];
by_provider: Row<"provider">[]; by_provider: Row<"provider">[];
by_agent: Row<"agent">[]; by_agent: Row<"agent">[];
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
type HeatMetric = "requests" | "tokens" | "cost"; type HeatMetric = "requests" | "tokens" | "cost";
/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */
function monthRange(fromMs: number, toMs: number): string[] {
const out: string[] = [];
const d = new Date(fromMs);
d.setDate(1);
d.setHours(0, 0, 0, 0);
for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
return out;
}
function dayKey(ms: number) { function dayKey(ms: number) {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -51,6 +64,44 @@ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[]
return out; return out;
} }
const FOLD_KEY = "codecraft.statsFolded";
function foldedSet(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]"));
} catch {
return new Set();
}
}
/** Panel with a title that folds the content; the state is remembered per device. */
function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) {
const [closed, setClosed] = useState(() => foldedSet().has(id));
const toggle = () => {
const next = !closed;
setClosed(next);
try {
const set = foldedSet();
if (next) set.add(id);
else set.delete(id);
localStorage.setItem(FOLD_KEY, JSON.stringify([...set]));
} catch {
/* storage unavailable */
}
};
return (
<div className={`panel fold${closed ? " closed" : ""}`}>
<h3>
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
<span className="fold-caret" aria-hidden="true" />
{title}
</button>
</h3>
{!closed && children}
</div>
);
}
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) { function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
return ( return (
<div className="kpi"> <div className="kpi">
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) {
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–"; return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
} }
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) { function AggTable({
rows,
keyName,
label,
currency,
link,
group,
}: {
rows: (Aggregate & Record<string, any>)[];
keyName: string;
label: string;
currency: string;
link?: (r: any) => void;
/** Group key of a row (e.g. the provider of a model): groups fold. */
group?: (r: any) => string;
}) {
const t = useT(); const t = useT();
const [shut, setShut] = useState<Set<string>>(new Set());
const groups = group ? [...new Set(rows.map(group))] : [];
const row = (r: (typeof rows)[number]) => (
<tr key={r[keyName]}>
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
);
return ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<table className="grid"> <table className="grid">
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {group
<tr key={r[keyName]}> ? groups.map((g) => {
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td> const members = rows.filter((r) => group(r) === g);
<td className="num">{fmtNumber(r.requests)}</td> const open = !shut.has(g);
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td> return (
<td className="num">{fmtCompact(r.cache_read_tokens)}</td> <Fragment key={g}>
<td className="num">{fmtCompact(r.output_tokens)}</td> <tr className="fold-group">
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td> <td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td> <button
<td className="num">{fmtNumber(r.prefill_tps)}</td> className="fold-toggle"
<td className="num">{fmtCost(r.cost, currency)}</td> aria-expanded={open}
<td className="num">{perMillion(r, currency)}</td> onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
>
<span className="fold-caret" aria-hidden="true" />
{g} <span className="hint">({members.length})</span>
</button>
</td>
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
<td colSpan={6} />
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
<td />
</tr> </tr>
))} {open && members.map(row)}
</Fragment>
);
})
: rows.map(row)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const isAdmin = useStore((s) => s.me?.role === "admin"); const isAdmin = useStore((s) => s.me?.role === "admin");
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? []; const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
const [userFilter, setUserFilter] = useState(""); const [userFilter, setUserFilter] = useState("");
const days = RANGES.find((r) => r.key === range)!.days;
const tz = -new Date().getTimezoneOffset(); const tz = -new Date().getTimezoneOffset();
// Stable per range: rounded to the minute so the URL doesn't change on every render. // Stable per range: rounded to the minute so the URL doesn't change on every render.
const now = Math.floor(Date.now() / 60000) * 60000 + 60000; const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
const from = days ? now - days * 86400000 : 0; // "y2025": that calendar year (local time); otherwise a rolling window.
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0;
const days = RANGES.find((r) => r.key === range)?.days ?? 0;
const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0;
const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
/** Download of what is shown: all tables as XLSX, or one table as CSV. */
const exportAs = (what: string) => {
if (!what) return;
const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`;
const a = document.createElement("a");
a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
};
// Years since the first recorded request (the data survives indefinitely).
const [firstYear, setFirstYear] = useState(new Date().getFullYear());
useEffect(() => {
if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear());
}, [data?.first_ts]);
const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i);
const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000;
// Long ranges: one bar per month instead of one per day.
const monthly = spanDays > 120;
const view = useMemo(() => { const view = useMemo(() => {
if (!data) return undefined; if (!data) return undefined;
const cats = dayRange(data.from, data.to, data.by_day[0]?.day); const bucket = (day: string) => (monthly ? day.slice(0, 7) : day);
const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day);
// Up to 7 models by cost get their own color; the rest fold into "Other". // Up to 7 models by cost get their own color; the rest fold into "Other".
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests); const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model); const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
@@ -135,7 +254,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const costByDay = cats.map(() => ({}) as Record<string, number>); const costByDay = cats.map(() => ({}) as Record<string, number>);
const idx = new Map(cats.map((c, i) => [c, i])); const idx = new Map(cats.map((c, i) => [c, i]));
for (const r of data.by_day_model) { for (const r of data.by_day_model) {
const i = idx.get(r.day); const i = idx.get(bucket(r.day));
if (i === undefined) continue; if (i === undefined) continue;
const key = named.includes(r.model) ? r.model : other; const key = named.includes(r.model) ? r.model : other;
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost; costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
@@ -146,7 +265,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{ key: "output", label: t("stats.output"), color: SERIES[2] }, { key: "output", label: t("stats.output"), color: SERIES[2] },
]; ];
const tokensByDay = cats.map((c): Record<string, number> => { const tokensByDay = cats.map((c): Record<string, number> => {
const d = data.by_day.find((x) => x.day === c); const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c);
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {}; return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
}); });
// Rolling window: the last 14 days, today at the bottom. // Rolling window: the last 14 days, today at the bottom.
@@ -174,10 +293,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) }; return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
}); });
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
}, [data, heat, t]); }, [data, heat, t, monthly]);
const dayLabel = (d: string) => const dayLabel = (d: string) =>
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`)); monthly
? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`))
: new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
const cur = data?.currency ?? "USD"; const cur = data?.currency ?? "USD";
const tot = data?.total; const tot = data?.total;
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v)); const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
</button> </button>
))} ))}
</div> </div>
{years.length > 1 && (
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
<option value="">{t("stats.year")}</option>
{years.map((y) => (
<option key={y} value={`y${y}`}>
{y}
</option>
))}
</select>
)}
{isAdmin && ( {isAdmin && (
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}> <select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
<option value="">{t("stats.allUsers")}</option> <option value="">{t("stats.allUsers")}</option>
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
))} ))}
</select> </select>
)} )}
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
<option value="">{t("stats.export")}</option>
<option value="xlsx">{t("stats.exportXlsx")}</option>
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
<option key={k} value={`csv:${k}`}>
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
</option>
))}
</select>
<button className="small" onClick={() => setTableView(!tableView)}> <button className="small" onClick={() => setTableView(!tableView)}>
{tableView ? t("stats.chartView") : t("stats.tableView")} {tableView ? t("stats.chartView") : t("stats.tableView")}
</button> </button>
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{tot.requests === 0 ? ( {tot.requests === 0 ? (
<div className="empty">{t("stats.noData")}</div> <div className="empty">{t("stats.noData")}</div>
) : tableView ? ( ) : tableView ? (
<div className="panel"> <Fold id="perDay" title={t("stats.perDay")}>
<h3>{t("stats.perDay")}</h3>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} /> <AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div> </Fold>
) : ( ) : (
<> <>
<div className="panel"> <Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
<h3>{t("stats.costPerDay")}</h3>
<Legend series={view.modelSeries} /> <Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div> </Fold>
<div className="panel"> <Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
<h3>{t("stats.tokensPerDay")}</h3>
<Legend series={view.tokenSeries} /> <Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} /> <StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div> </Fold>
<div className="panel"> <Fold id="heatmap" title={t("stats.heatmap")}>
<h3> <div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
{t("stats.heatmap")} <select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
<option value="requests">{t("stats.requests")}</option> <option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option> <option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option> <option value="cost">{t("stats.cost")}</option>
</select> </select>
</h3>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</div> </div>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</Fold>
</> </>
)} )}
{tot.requests > 0 && ( {tot.requests > 0 && (
<> <>
<div className="panel"> <Fold id="byModel" title={t("stats.byModel")}>
<h3>{t("stats.byModel")}</h3> <AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} /> </Fold>
</div> <Fold id="byProvider" title={t("stats.byProvider")}>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} /> <AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div> </Fold>
<div className="panel"> <Fold id="byAgent" title={t("stats.byAgent")}>
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} /> <AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div> </Fold>
{isAdmin && !userFilter && data.by_user.length > 1 && ( {isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel"> <Fold id="byUser" title={t("stats.byUser")}>
<h3>{t("stats.byUser")}</h3>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} /> <AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div> </Fold>
)} )}
<div className="panel"> <Fold id="topSessions" title={t("stats.topSessions")}>
<h3>{t("stats.topSessions")}</h3>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} /> <AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
</div> </Fold>
</> </>
)} )}
</> </>
+1 -1
View File
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0; const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault = const openByDefault =
(status === "err" && !!result) || (status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d")); (!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
const open = toggled ?? openByDefault; const open = toggled ?? openByDefault;
return ( return (
+85
View File
@@ -0,0 +1,85 @@
import { useState } from "react";
import { get } from "../api";
import { useT } from "../i18n";
import type { Message } from "../types";
import { Diff } from "./Diff";
import { Icon } from "./Icon";
import { basename } from "./FileFigure";
interface Change {
path: string;
created: boolean;
deleted?: boolean;
diff?: string;
truncated?: boolean;
}
const count = (diff: string | undefined, sign: "+" | "-") => (diff ?? "").split("\n").filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).length;
/**
* What one turn changed: files with their diffs (from the checkpoints the agent leaves before
* every write/edit) and a button to roll the chat and the files back to before the turn.
*/
export function TurnChanges({
sessionId,
fromId,
untilId,
fileCount,
userMessage,
onRewind,
}: {
sessionId: string;
fromId: string;
untilId?: string;
fileCount: number;
userMessage?: Message;
onRewind?: (m: Message) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const [changes, setChanges] = useState<Change[]>();
const [error, setError] = useState<string>();
const toggle = () => {
const next = !open;
setOpen(next);
if (next && !changes) {
const q = `since_message=${encodeURIComponent(fromId)}&diff=true${untilId ? `&until_message=${encodeURIComponent(untilId)}` : ""}`;
get<Change[]>(`/sessions/${sessionId}/changes?${q}`).then(setChanges, (e) => setError(e.message));
}
};
return (
<div className="turn-changes">
<button className="small ghost" onClick={toggle} aria-expanded={open}>
<Icon name={open ? "chevron-down" : "chevron-right"} className="tiny" /> {t("turn.changes", { n: fileCount })}
</button>
{open && (
<div className="turn-changes-body">
{error && <div className="notice err">{error}</div>}
{!changes && !error && <div className="hint">…</div>}
{changes?.length === 0 && <div className="hint">{t("turn.noChanges")}</div>}
{changes?.map((c) => (
<details key={c.path} className="turn-file">
<summary title={c.path}>
<span className="mono">{basename(c.path)}</span>
{c.created && <span className="chip">{t("turn.new")}</span>}
{c.deleted && <span className="chip warn">{t("turn.deleted")}</span>}
<span className="turn-stat">
<span className="add">+{count(c.diff, "+")}</span> <span className="del">−{count(c.diff, "-")}</span>
</span>
</summary>
{c.diff ? <Diff diff={c.diff} /> : <div className="hint">{t("turn.noDiff")}</div>}
{c.truncated && <div className="hint">{t("turn.truncated")}</div>}
</details>
))}
{userMessage && onRewind && changes && changes.length > 0 && (
<button className="small" onClick={() => onRewind(userMessage)}>
<Icon name="undo" /> {t("turn.rollback")}
</button>
)}
</div>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useState } from "react";
import { post } from "../api";
import { toast } from "../dialogs";
import { useT } from "../i18n";
import { useStore } from "../store";
import { Spinner } from "./Activity";
import { Icon } from "./Icon";
/** Green/red badge of the project's check (tests, build …) for one turn; output and "let the agent fix it" on click. */
export function TurnCheck({ sessionId, messageId, canSend }: { sessionId: string; messageId: string; canSend: boolean }) {
const t = useT();
const check = useStore((s) => s.checks[sessionId]?.[messageId]);
const [open, setOpen] = useState(false);
if (!check) return null;
const seconds = check.duration_ms >= 1000 ? ` · ${(check.duration_ms / 1000).toFixed(check.duration_ms >= 10_000 ? 0 : 1)} s` : "";
const label =
check.status === "running" ? t("check.running") : check.status === "passed" ? t("check.passed") : check.status === "timeout" ? t("check.timeout") : t("check.failed");
const fix = async () => {
try {
await post(`/sessions/${sessionId}/prompt`, { text: `${t("check.fixPrompt")}\n\n\`\`\`\n${check.output}\n\`\`\`` });
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="turn-check">
<button className={`chip check-${check.status}`} onClick={() => setOpen(!open)} aria-expanded={open} disabled={check.status === "running"}>
{check.status === "running" ? <Spinner small /> : <Icon name={check.status === "passed" ? "check" : "x-mark"} className="tiny" />} {label}
{seconds}
</button>
{open && check.status !== "running" && (
<div className="turn-check-body">
<pre className="mono">{check.output || t("check.noOutput")}</pre>
{check.status !== "passed" && canSend && (
<button className="small primary" onClick={fix}>
<Icon name="sparkle" /> {t("check.fix")}
</button>
)}
</div>
)}
</div>
);
}
+78 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n"; import { getLang, useT } from "../i18n";
import { toast } from "../dialogs"; import { toast } from "../dialogs";
import { get } from "../api";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ /* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
@@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit
export const voiceSupported = () => !!Impl; export const voiceSupported = () => !!Impl;
/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */
function useServerSpeech(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Impl) return;
get<{ transcribe?: boolean }>("/config")
.then((c) => setOn(!!c.transcribe))
.catch(() => undefined);
}, []);
return on;
}
/** Record with the microphone; the audio is transcribed by the server. */
function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [state, setState] = useState<"idle" | "recording" | "sending">("idle");
const rec = useRef<MediaRecorder | null>(null);
useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []);
const toggle = async () => {
if (state === "recording") {
rec.current?.stop();
return;
}
if (state === "sending") return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const r = new MediaRecorder(stream);
const chunks: Blob[] = [];
r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
r.onstop = async () => {
stream.getTracks().forEach((x) => x.stop());
setState("sending");
onInterim(t("voice.transcribing"));
try {
const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" });
const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
if (data.text) onText(String(data.text));
} catch (e: any) {
toast(`${t("voice.error")}: ${e.message}`);
} finally {
onInterim("");
setState("idle");
}
};
rec.current = r;
r.start();
setState("recording");
} catch {
toast(t("voice.denied"));
}
};
return (
<button
type="button"
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
disabled={state === "sending"}
onClick={() => void toggle()}
title={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-pressed={state === "recording"}
>
<Icon name="mic" />
</button>
);
}
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" }; const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */ /** Microphone button: dictated text is appended to the message. */
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const server = useServerSpeech();
if (!Impl) return server ? <RecorderButton {...props} /> : null;
return <BrowserVoiceButton {...props} />;
}
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT(); const t = useT();
const [listening, setListening] = useState(false); const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null); const rec = useRef<Recognition | null>(null);
+6
View File
@@ -3,9 +3,12 @@ import { post } from "../../api";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { GitAccount } from "./GitAccount"; import { GitAccount } from "./GitAccount";
import { SshKeys } from "./SshKeys";
import { ApiTokens } from "./ApiTokens"; import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall"; import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory"; import { Memory } from "./Memory";
import { Snippets } from "./Snippets";
import { ClaudeCode } from "./ClaudeCode";
import { Telegram } from "./Telegram"; import { Telegram } from "./Telegram";
export function Account() { export function Account() {
@@ -50,8 +53,11 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div> <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div> </div>
<Memory /> <Memory />
<Snippets />
<GitAccount /> <GitAccount />
<SshKeys />
<CliInstall /> <CliInstall />
<ClaudeCode />
<Telegram /> <Telegram />
<ApiTokens /> <ApiTokens />
</> </>
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { api, get, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
import { Spinner } from "../Activity";
import { useStore } from "../../store";
interface Status {
installed: boolean;
logged_in?: boolean;
method?: string;
email?: string;
org?: string;
expires?: string;
login_in_progress?: boolean;
error?: string;
version?: string | null;
latest?: string | null;
update_available?: boolean;
output?: string;
}
/**
* Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows
* a code after login that is pasted here and handed back to the CLI on the server.
*/
export function ClaudeCode() {
const t = useT();
const [status, setStatus] = useState<Status>();
const [url, setUrl] = useState<string>();
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [console_, setConsole] = useState(false);
const isAdmin = useStore((s) => s.me?.role === "admin");
const [updating, setUpdating] = useState(false);
const [checking, setChecking] = useState(false);
const [updateLog, setUpdateLog] = useState<string>();
const check = async () => {
setChecking(true);
try {
setStatus(await get<Status>("/claude/status?refresh=true"));
} catch (e: any) {
toast(e.message);
} finally {
setChecking(false);
}
};
const update = async () => {
setUpdating(true);
setUpdateLog(undefined);
try {
const r = await post<Status>("/claude/update", {});
setStatus(r);
setUpdateLog(r.output);
toast(t("claude.updated", { version: r.version ?? "" }), "success");
} catch (e: any) {
setUpdateLog(e.message);
toast(t("claude.updateFailed"));
} finally {
setUpdating(false);
}
};
const load = () => get<Status>("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message }));
useEffect(() => void load(), []);
const start = async () => {
setBusy(true);
try {
const r = await post<{ url: string }>("/claude/login", { console: console_ });
setUrl(r.url);
setCode("");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const submit = async () => {
setBusy(true);
try {
setStatus(await post<Status>("/claude/login/code", { code }));
setUrl(undefined);
setCode("");
toast(t("claude.signedIn"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const cancel = async () => {
await api("/claude/login", { method: "DELETE" }).catch(() => undefined);
setUrl(undefined);
setCode("");
};
const logout = async () => {
if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return;
try {
setStatus(await post<Status>("/claude/logout", {}));
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="panel">
<h3>
<Icon name="robot" /> {t("claude.title")}
<span className="spacer" />
{status?.logged_in && (
<button className="small ghost danger" onClick={logout}>
{t("claude.logout")}
</button>
)}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>{t("claude.intro")}</div>
{!status && <Spinner />}
{status && !status.installed && <div className="notice err">{status.error ?? t("claude.notInstalled")}</div>}
{status?.installed && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
<span className="chip mono">{t("claude.version", { version: status.version ?? "?" })}</span>
{status.update_available ? (
<>
<span className="chip warn">{t("claude.updateAvailable", { version: status.latest ?? "" })}</span>
{isAdmin ? (
<button className="small primary" disabled={updating} onClick={update}>
{updating ? <Spinner small /> : <Icon name="download" />} {updating ? t("claude.updating") : t("claude.update")}
</button>
) : (
<span className="hint">{t("claude.updateAdmin")}</span>
)}
</>
) : (
status.latest && <span className="chip">{t("claude.upToDate")}</span>
)}
<button className="small ghost" disabled={checking || updating} onClick={check} title={t("claude.checkNow")} aria-label={t("claude.checkNow")}>
{checking ? <Spinner small /> : <Icon name="refresh" />}
</button>
</div>
)}
{updateLog && (
<details style={{ marginBottom: 10 }}>
<summary className="hint" style={{ cursor: "pointer" }}>{t("claude.updateLog")}</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>{updateLog}</pre>
</details>
)}
{status?.installed && status.logged_in && (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="chip">
<Icon name="check" className="tiny" /> {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })}
</span>
{status.method && <span className="chip">{status.method}</span>}
{status.expires && <span className="hint">{t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}</span>}
</div>
)}
{status?.installed && !status.logged_in && !url && (
<>
<label className="row" style={{ marginBottom: 8 }}>
<input type="checkbox" checked={console_} onChange={(e) => setConsole(e.target.checked)} /> {t("claude.console")}
</label>
<button className="primary" disabled={busy} onClick={start}>
{busy ? <Spinner small /> : <Icon name="lock" />} {t("claude.signIn")}
</button>
</>
)}
{url && (
<ol className="cli-steps">
<li>
<a className="button" href={url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("claude.openLogin")}
</a>
<div className="hint">{t("claude.step1")}</div>
</li>
<li>
{t("claude.step2")}
<div className="row" style={{ marginTop: 6 }}>
<input style={{ flex: 1 }} value={code} placeholder={t("claude.codePlaceholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} />
<button className="primary" disabled={busy || !code.trim()} onClick={submit}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("claude.confirm")}
</button>
<button onClick={cancel}>{t("common.cancel")}</button>
</div>
</li>
</ol>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
export interface Snippet {
id?: string;
name: string;
text: string;
}
/** Personal prompt snippets: typed as `/name` in the composer. */
export function Snippets() {
const t = useT();
const [items, setItems] = useState<Snippet[]>();
useEffect(() => {
get<Snippet[]>("/account/snippets")
.then(setItems)
.catch((e) => toast(e.message));
}, []);
if (!items) return null;
const set = (i: number, patch: Partial<Snippet>) => setItems(items.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const save = () => {
const clean = items.filter((s) => s.name.trim() || s.text.trim()).map((s) => ({ ...s, name: s.name.trim().replace(/^\/+/, "").replace(/\s+/g, "-") }));
put<Snippet[]>("/account/snippets", clean)
.then((r) => (setItems(r), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
};
return (
<div className="panel">
<h3>
<Icon name="sparkle" /> {t("snippets.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("snippets.hint")}
</div>
{items.map((s, i) => (
<div key={s.id ?? i} className="snippet-row">
<div className="row">
<span className="snippet-slash">/</span>
<input value={s.name} placeholder={t("snippets.name")} maxLength={40} onChange={(e) => set(i, { name: e.target.value })} aria-label={t("snippets.name")} />
<button title={t("common.delete")} aria-label={t("common.delete")} onClick={() => setItems(items.filter((_, j) => j !== i))}>
<Icon name="trash" />
</button>
</div>
<textarea rows={2} value={s.text} placeholder={t("snippets.text")} onChange={(e) => set(i, { text: e.target.value })} aria-label={t("snippets.text")} />
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => setItems([...items, { name: "", text: "" }])}>
<Icon name="plus" /> {t("snippets.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { del, get, post } from "../../api";
import { useT } from "../../i18n";
import { confirmDialog, toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Key {
id: string;
name: string;
public: string;
fingerprint: string;
}
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
export function SshKeys() {
const t = useT();
const [keys, setKeys] = useState<Key[]>();
const [name, setName] = useState("");
const [priv, setPriv] = useState("");
const [importing, setImporting] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Key[]>("/account/ssh")
.then(setKeys)
.catch((e) => toast(e.message));
}, []);
if (!keys) return null;
const add = async () => {
setBusy(true);
try {
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
setName("");
setPriv("");
toast(t("ssh.added"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
return (
<div className="panel">
<h3>
<Icon name="lock" /> {t("ssh.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("ssh.hint")}
</div>
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
{keys.map((k) => (
<div key={k.id} className="ssh-key">
<div className="row">
<b>{k.name}</b>
<span className="hint mono">{k.fingerprint}</span>
<span className="spacer" />
<button className="small" onClick={() => copy(k.public)}>
<Icon name="copy" /> {t("ssh.copyPublic")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
}
}}
>
<Icon name="trash" />
</button>
</div>
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
</div>
))}
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
{importing ? t("ssh.import") : t("ssh.generate")}
</button>
<label className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
{t("ssh.importExisting")}
</label>
</div>
{importing && (
<>
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
<div className="hint">{t("ssh.importHint")}</div>
</>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/** Put content into the composer of a chat from anywhere in the app (quote, image viewer, …). */
export interface ComposeDetail {
sessionId: string;
/** Appended to the text field (a blank line before it if there is text already). */
text?: string;
/** Attached like dropped files (images stay images). */
files?: File[];
}
export const COMPOSE_EVENT = "codecraft:compose";
export function compose(detail: ComposeDetail) {
window.dispatchEvent(new CustomEvent<ComposeDetail>(COMPOSE_EVENT, { detail }));
}
/** "> quoted\n> lines\n\n" for the composer. */
export function asQuote(text: string) {
const lines = text.trim().split("\n").map((l) => `> ${l}`.trimEnd());
return `${lines.join("\n")}\n\n`;
}
+28
View File
@@ -20,8 +20,15 @@ interface PromptOpts {
confirmLabel?: string; confirmLabel?: string;
} }
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog = type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts) | ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts); | ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast { interface Toast {
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
}); });
} }
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> { export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts; const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -108,6 +123,18 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
)} )}
{d.kind === "choice" ? (
<div className="row dialog-actions dialog-choices">
<button type="button" onClick={() => close(d, null)}>
{t("common.cancel")}
</button>
{d.choices.map((c, i) => (
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
{c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions"> <div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}> <button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")} {t("common.cancel")}
@@ -116,6 +143,7 @@ function DialogView({ d }: { d: Dialog }) {
{d.confirmLabel ?? t("common.ok")} {d.confirmLabel ?? t("common.ok")}
</button> </button>
</div> </div>
)}
</form> </form>
</div> </div>
); );
+171
View File
@@ -366,6 +366,100 @@ export const de: Messages = {
"media.showInChat": "Im Chat zeigen", "media.showInChat": "Im Chat zeigen",
"media.open": "Öffnen", "media.open": "Öffnen",
"media.prev": "Vorheriges", "media.prev": "Vorheriges",
"msg.actions": "Nachrichtenaktionen",
"msg.copy": "Text kopieren",
"msg.copied": "Kopiert",
"msg.copyFailed": "Kopieren ist hier nicht möglich",
"msg.quote": "In Antwort zitieren",
"msg.forkHere": "Ab hier abzweigen",
"chat.jumpPrevOwn": "Vorherige eigene Nachricht",
"viewer.annotate": "Markieren",
"viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten",
"viewer.attach": "An Nachricht anhängen",
"viewer.undo": "Rückgängig",
"viewer.tool.pen": "Stift",
"viewer.tool.arrow": "Pfeil",
"viewer.tool.rect": "Rechteck",
"viewer.useInChat": "In Nachricht verwenden",
"viewer.attached": "Bild an die Nachricht angehängt",
"turn.changes": "{n} Datei(en) geändert",
"turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.",
"turn.noDiff": "Kein Text-Diff verfügbar.",
"turn.truncated": "Der Diff ist gekürzt.",
"turn.new": "neu",
"turn.deleted": "gelöscht",
"turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen",
"job.plan": "Als Automatisierung planen",
"job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)",
"job.drafting": "Automatisierung wird aus diesem Chat entworfen …",
"color.label": "Farbe dieses Chats",
"color.red": "Rot",
"color.orange": "Orange",
"color.yellow": "Gelb",
"color.green": "Grün",
"color.aqua": "Türkis",
"color.blue": "Blau",
"color.purple": "Violett",
"color.pink": "Pink",
"icon.label": "Symbol dieses Chats",
"claude.title": "Claude Code",
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
"claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo",
"claude.signIn": "Anmelden",
"claude.openLogin": "Anmeldeseite öffnen",
"claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.",
"claude.step2": "Den Code einfügen, den die Seite danach anzeigt:",
"claude.codePlaceholder": "Code von der Anmeldeseite",
"claude.confirm": "Bestätigen",
"claude.signedIn": "Bei Claude Code angemeldet",
"claude.signedInAs": "Angemeldet: {who}",
"claude.expires": "gültig bis {date}",
"claude.logout": "Abmelden",
"claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?",
"claude.version": "CLI {version}",
"claude.updateAvailable": "Update verfügbar: {version}",
"claude.upToDate": "aktuell",
"claude.update": "Update installieren",
"claude.updating": "Installiere …",
"claude.updated": "Claude Code auf {version} aktualisiert",
"claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)",
"claude.updateAdmin": "Ein Administrator kann es installieren.",
"claude.checkNow": "Nach Updates suchen",
"claude.updateLog": "Ausgabe des Updates",
"check.menu": "Prüfung nach jedem Schritt …",
"check.title": "Prüfung nach jedem Schritt",
"check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.",
"check.saved": "Prüfung gespeichert",
"check.removed": "Prüfung entfernt",
"check.running": "Prüfung läuft …",
"check.passed": "Prüfung bestanden",
"check.failed": "Prüfung fehlgeschlagen",
"check.timeout": "Prüfung: Zeitüberschreitung",
"check.noOutput": "(keine Ausgabe)",
"check.fix": "Vom Agenten beheben lassen",
"check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:",
"projectDefaults.save": "Als Projekt-Standard verwenden",
"projectDefaults.remove": "Projekt-Standard entfernen",
"projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats",
"projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser",
"projectDefaults.removed": "Projekt-Standard entfernt",
"outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung",
"outbox.sendNow": "Jetzt senden",
"outbox.discard": "Verwerfen",
"outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist",
"project.open": "Projektübersicht",
"project.newChat": "Neuer Chat",
"project.today": "Heute",
"project.yesterday": "Gestern",
"project.chats": "{n} Chat(s)",
"project.files": "{n} Datei(en) geändert",
"project.archived": "{n} archiviert",
"project.checksFailed": "{n} Prüfung(en) fehlgeschlagen",
"project.empty": "In diesem Ordner gibt es noch keine Chats.",
"viewer.title": "Bildbetrachter",
"viewer.open": "In neuem Tab öffnen",
"viewer.download": "Herunterladen",
"media.next": "Nächstes", "media.next": "Nächstes",
"media.back": "Zurück zur Übersicht", "media.back": "Zurück zur Übersicht",
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)", "chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
@@ -724,6 +818,16 @@ export const de: Messages = {
"permission.title": "Freigabe erforderlich", "permission.title": "Freigabe erforderlich",
"permission.allowOnce": "Einmal erlauben", "permission.allowOnce": "Einmal erlauben",
"permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben",
"permission.projectTool": "{tool} in diesem Projekt immer erlauben",
"permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.",
"permission.explain": "Was macht das?",
"permission.explaining": "Erkläre …",
"permProject.title": "Projekt-Freigaben",
"permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):",
"permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.",
"permProject.revoke": "Widerrufen",
"permProject.menu": "Projekt-Freigaben",
"permission.alwaysTool": "{tool} in dieser Session immer erlauben", "permission.alwaysTool": "{tool} in dieser Session immer erlauben",
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben", "permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
"permission.reject": "Ablehnen", "permission.reject": "Ablehnen",
@@ -997,4 +1101,71 @@ export const de: Messages = {
"stats.user": "Benutzer", "stats.user": "Benutzer",
"stats.allUsers": "Alle Benutzer", "stats.allUsers": "Alle Benutzer",
"stats.byUser": "Nach Benutzer", "stats.byUser": "Nach Benutzer",
"snippets.title": "Prompt-Bausteine",
"snippets.hint": "Eigene Textbausteine. Tippe / im Nachrichtenfeld und wähle einen Baustein, um ihn einzufügen (z. B. /tests → \"Schreibe Tests zu dieser Änderung\").",
"snippets.name": "Name",
"snippets.text": "Einzufügender Text",
"snippets.add": "Baustein hinzufügen",
"snippets.insert": "Baustein",
"link.title": "Nur-Lese-Link",
"link.hint": "Wer den Link hat, kann den Gesprächstext lesen (keine Tool-Ausgaben, keine Dateien) – ohne Konto.",
"link.create": "Link erstellen",
"link.copy": "Kopieren",
"link.copied": "Link kopiert",
"link.revoke": "Widerrufen",
"link.validity": "Gültigkeit",
"link.days": "{n} Tage",
"link.until": "bis {date}",
"link.readOnly": "Schreibgeschützt geteilt",
"git.suggest": "Nachricht vorschlagen",
"git.suggestHint": "Das kleine Modell schreibt eine Commit-Nachricht für die vorgemerkten Änderungen",
"settings.weeklyDigest": "Wochenrückblick montags um 8:00 (Chats, Checks, Kosten)",
"settings.transcribeModel": "Spracherkennungs-Modell",
"settings.transcribeHint": "provider/modell eines OpenAI-kompatiblen Transkriptions-Endpunkts (z. B. openai/whisper-1). Schaltet den Mikrofon-Knopf in Browsern ohne eigene Diktierfunktion frei, etwa Firefox.",
"voice.transcribing": "Wird transkribiert …",
"offline.banner": "Keine Verbindung – gespeicherte Kopien. Geschriebene Nachrichten warten im Postausgang.",
"settings.planModel": "Planungsmodell",
"settings.planModelHint": "Der Plan-Modus nutzt immer dieses Modell (z. B. ein starkes); alle anderen Modi das im Chat gewählte (z. B. ein günstigeres). Leer = Modell des Chats.",
"settings.sameAsChat": "(Modell des Chats)",
"jobEditor.notify": "Bericht als Push-Nachricht senden, wenn ein Lauf endet",
"stats.year": "Jahr",
"stats.month": "Monat",
"stats.perMonth": "Pro Monat",
"stats.costPerMonth": "Kosten pro Monat",
"stats.tokensPerMonth": "Tokens pro Monat",
"stats.export": "Export",
"stats.exportHint": "Die Werte des gewählten Zeitraums herunterladen",
"stats.exportXlsx": "Excel (.xlsx) – alle Tabellen",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "pro Tag",
"stats.table.month": "pro Monat",
"stats.table.model": "nach Modell",
"stats.table.provider": "nach Anbieter",
"stats.table.agent": "nach Modus",
"stats.table.user": "nach Benutzer",
"stats.table.session": "teuerste Chats",
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
"ssh.title": "SSH-Schlüssel",
"ssh.hint": "Schlüssel, mit denen Agenten, Terminal und Git-Panel andere Server erreichen (ssh, scp, rsync, Git über ssh). Sie werden automatisch genutzt; Hosts werden beim ersten Kontakt akzeptiert. Den öffentlichen Schlüssel trägst du in die authorized_keys des Servers ein.",
"ssh.none": "Noch keine Schlüssel.",
"ssh.name": "Name (z. B. Laptop, Build-Server)",
"ssh.generate": "Neuen Schlüssel erzeugen",
"ssh.import": "Schlüssel importieren",
"ssh.importExisting": "Vorhandenen Schlüssel importieren",
"ssh.importHint": "Nur private Schlüssel ohne Passphrase. Der Schlüssel wird für dein Konto auf dem Server gespeichert.",
"ssh.private": "Privater Schlüssel",
"ssh.public": "Öffentlicher Schlüssel",
"ssh.copyPublic": "Öffentlichen Schlüssel kopieren",
"ssh.copied": "Öffentlicher Schlüssel kopiert",
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
"ssh.added": "Schlüssel hinzugefügt",
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
"chat.deleteTitle": "Chat löschen",
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
"chat.deleteOnlyChat": "Nur Chat löschen",
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
"chat.folderFiles": "{n} Dateien",
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
"chat.folderDeleted": "Chat und Ordner gelöscht",
}; };
+171
View File
@@ -366,6 +366,100 @@ export const en = {
"media.open": "Open", "media.open": "Open",
"media.prev": "Previous", "media.prev": "Previous",
"media.next": "Next", "media.next": "Next",
"msg.actions": "Message actions",
"msg.copy": "Copy text",
"msg.copied": "Copied",
"msg.copyFailed": "Copying is not possible here",
"msg.quote": "Quote in reply",
"msg.forkHere": "Fork from here",
"chat.jumpPrevOwn": "Previous own message",
"viewer.annotate": "Mark up",
"viewer.annotateFailed": "This image cannot be edited",
"viewer.attach": "Attach to message",
"viewer.undo": "Undo",
"viewer.tool.pen": "Pen",
"viewer.tool.arrow": "Arrow",
"viewer.tool.rect": "Rectangle",
"viewer.useInChat": "Use in message",
"viewer.attached": "Image attached to the message",
"turn.changes": "{n} file(s) changed",
"turn.noChanges": "No file changes found for this turn.",
"turn.noDiff": "No text diff available.",
"turn.truncated": "The diff is shortened.",
"turn.new": "new",
"turn.deleted": "deleted",
"turn.rollback": "Roll back to before this turn",
"job.plan": "Plan as automation",
"job.planHint": "Turns this chat into instructions for a recurring automation (switched off until you set the schedule)",
"job.drafting": "Drafting the automation from this chat …",
"color.label": "Colour of this chat",
"color.red": "Red",
"color.orange": "Orange",
"color.yellow": "Yellow",
"color.green": "Green",
"color.aqua": "Teal",
"color.blue": "Blue",
"color.purple": "Purple",
"color.pink": "Pink",
"icon.label": "Icon of this chat",
"claude.title": "Claude Code",
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
"claude.notInstalled": "Claude Code is not installed on the server.",
"claude.console": "Anthropic Console (API billing) instead of the Claude subscription",
"claude.signIn": "Sign in",
"claude.openLogin": "Open the sign-in page",
"claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.",
"claude.step2": "Paste the code the page shows afterwards:",
"claude.codePlaceholder": "Code from the sign-in page",
"claude.confirm": "Confirm",
"claude.signedIn": "Signed in to Claude Code",
"claude.signedInAs": "Signed in: {who}",
"claude.expires": "valid until {date}",
"claude.logout": "Sign out",
"claude.logoutConfirm": "Sign out of Claude Code on the server?",
"claude.version": "CLI {version}",
"claude.updateAvailable": "Update available: {version}",
"claude.upToDate": "up to date",
"claude.update": "Install update",
"claude.updating": "Installing …",
"claude.updated": "Claude Code updated to {version}",
"claude.updateFailed": "The update failed (details below)",
"claude.updateAdmin": "An administrator can install it.",
"claude.checkNow": "Check for updates",
"claude.updateLog": "Output of the update",
"check.menu": "Check after each turn …",
"check.title": "Check after each turn",
"check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.",
"check.saved": "Check saved",
"check.removed": "Check removed",
"check.running": "Check running …",
"check.passed": "Check passed",
"check.failed": "Check failed",
"check.timeout": "Check timed out",
"check.noOutput": "(no output)",
"check.fix": "Let the agent fix it",
"check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:",
"projectDefaults.save": "Use as project default",
"projectDefaults.remove": "Remove project default",
"projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder",
"projectDefaults.saved": "New chats in this project will start like this one",
"projectDefaults.removed": "Project default removed",
"outbox.waiting": "{n} message(s) waiting for a connection",
"outbox.sendNow": "Send now",
"outbox.discard": "Discard",
"outbox.saved": "No connection – the message is saved and sent when you are back online",
"project.open": "Project overview",
"project.newChat": "New chat",
"project.today": "Today",
"project.yesterday": "Yesterday",
"project.chats": "{n} chat(s)",
"project.files": "{n} file(s) changed",
"project.archived": "{n} archived",
"project.checksFailed": "{n} failed check(s)",
"project.empty": "No chats in this folder yet.",
"viewer.title": "Image viewer",
"viewer.open": "Open in new tab",
"viewer.download": "Download",
"media.back": "Back to the overview", "media.back": "Back to the overview",
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)", "chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
"chat.reviewRound": "Review {round}: changes requested", "chat.reviewRound": "Review {round}: changes requested",
@@ -723,6 +817,16 @@ export const en = {
"permission.title": "Permission required", "permission.title": "Permission required",
"permission.allowOnce": "Allow once", "permission.allowOnce": "Allow once",
"permission.projectCommand": "Always allow \"{prefix}\" in this project",
"permission.projectTool": "Always allow {tool} in this project",
"permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.",
"permission.explain": "What does this do?",
"permission.explaining": "Explaining …",
"permProject.title": "Project permissions",
"permProject.intro": "Allowed without asking in this project folder (all chats):",
"permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.",
"permProject.revoke": "Revoke",
"permProject.menu": "Project permissions",
"permission.alwaysTool": "Always allow {tool} in this session", "permission.alwaysTool": "Always allow {tool} in this session",
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session", "permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
"permission.reject": "Reject", "permission.reject": "Reject",
@@ -996,6 +1100,73 @@ export const en = {
"stats.user": "User", "stats.user": "User",
"stats.allUsers": "All users", "stats.allUsers": "All users",
"stats.byUser": "By user", "stats.byUser": "By user",
"snippets.title": "Prompt snippets",
"snippets.hint": "Your own text blocks. Type / in the message field and pick a snippet to insert it (e.g. /tests → \"Add tests for this change\").",
"snippets.name": "name",
"snippets.text": "Text to insert",
"snippets.add": "Add snippet",
"snippets.insert": "Snippet",
"link.title": "Read-only link",
"link.hint": "Anyone with the link can read the conversation text (no tool output, no files) – without an account.",
"link.create": "Create link",
"link.copy": "Copy",
"link.copied": "Link copied",
"link.revoke": "Revoke",
"link.validity": "Validity",
"link.days": "{n} days",
"link.until": "until {date}",
"link.readOnly": "Shared read-only",
"git.suggest": "Suggest message",
"git.suggestHint": "Let the small model write a commit message for the staged changes",
"settings.weeklyDigest": "Weekly review on Mondays at 8:00 (chats, checks, costs)",
"settings.transcribeModel": "Speech-to-text model",
"settings.transcribeHint": "provider/model of an OpenAI-compatible transcription endpoint (e.g. openai/whisper-1). Adds the microphone button in browsers without built-in dictation, such as Firefox.",
"voice.transcribing": "Transcribing …",
"offline.banner": "No connection – showing saved copies. Messages you write wait in the outbox.",
"settings.planModel": "Planning model",
"settings.planModelHint": "Plan mode always uses this model (e.g. a strong one); all other modes use the model chosen for the chat (e.g. a cheaper one). Empty = the chat's model.",
"settings.sameAsChat": "(model of the chat)",
"jobEditor.notify": "Send the report as a push notification when a run ends",
"stats.year": "Year",
"stats.month": "Month",
"stats.perMonth": "Per month",
"stats.costPerMonth": "Cost per month",
"stats.tokensPerMonth": "Tokens per month",
"stats.export": "Export",
"stats.exportHint": "Download the figures of the selected range",
"stats.exportXlsx": "Excel (.xlsx) – all tables",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "per day",
"stats.table.month": "per month",
"stats.table.model": "by model",
"stats.table.provider": "by provider",
"stats.table.agent": "by agent",
"stats.table.user": "by user",
"stats.table.session": "top chats",
"command.clear": "Forget the conversation so far (the chat stays visible)",
"chat.cleared": "Context cleared – the model no longer sees the messages above",
"ssh.title": "SSH keys",
"ssh.hint": "Keys that agents, the terminal and the git panel use to reach other servers (ssh, scp, rsync, git over ssh). They are used automatically; hosts are trusted on first contact. Add the public key to the server's authorized_keys.",
"ssh.none": "No keys yet.",
"ssh.name": "Name (e.g. laptop, build server)",
"ssh.generate": "Generate new key",
"ssh.import": "Import key",
"ssh.importExisting": "Import an existing key",
"ssh.importHint": "Private keys without a passphrase only. The key is stored on the server for your account.",
"ssh.private": "Private key",
"ssh.public": "Public key",
"ssh.copyPublic": "Copy public key",
"ssh.copied": "Public key copied",
"ssh.copyFailed": "Could not copy – select the text manually",
"ssh.added": "Key added",
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
"chat.deleteTitle": "Delete chat",
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
"chat.deleteOnlyChat": "Delete chat only",
"chat.deleteChatAndFolder": "Delete chat and folder",
"chat.folderFiles": "{n} files",
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
"chat.folderDeleted": "Chat and folder deleted",
}; };
export type Messages = Record<keyof typeof en, string>; export type Messages = Record<keyof typeof en, string>;
+7
View File
@@ -72,6 +72,13 @@ export function fmtCompact(n: number) {
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n); return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
} }
/** Short date and time of a message: only the time for today. */
export function fmtMsgTime(ms: number) {
const d = new Date(ms);
const today = d.toDateString() === new Date().toDateString();
return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d);
}
export function fmtCost(n: number, currency: string) { export function fmtCost(n: number, currency: string) {
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2; const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
try { try {
+86
View File
@@ -0,0 +1,86 @@
import type { Info, Message, Part, Session } from "./types";
/**
* Read-only copy of the app's last state for the offline case (train, tunnel): the chat
* list and the most recently opened chats. Kept small: attachments are dropped and long
* tool output is cut. Everything is best-effort – storage may be full or blocked.
*/
const KEY = "codecraft.offline.v1";
const MAX_CHATS = 6;
const MAX_MESSAGES = 80;
const MAX_BYTES = 2_500_000;
interface Cache {
info?: Info;
sessions?: Session[];
chats: Record<string, { at: number; session: Session; messages: Message[] }>;
}
function read(): Cache {
try {
const c = JSON.parse(localStorage.getItem(KEY) ?? "null");
if (c && typeof c === "object") return { chats: {}, ...c };
} catch {
/* empty or corrupt */
}
return { chats: {} };
}
function write(c: Cache) {
try {
// Oldest chats go first when the copy gets too big.
let text = JSON.stringify(c);
while (text.length > MAX_BYTES && Object.keys(c.chats).length > 0) {
const oldest = Object.entries(c.chats).sort((a, b) => a[1].at - b[1].at)[0][0];
delete c.chats[oldest];
text = JSON.stringify(c);
}
localStorage.setItem(KEY, text);
} catch {
/* storage full or unavailable */
}
}
function slim(part: Part): Part {
if (part.type === "image") return { type: "text", text: "[image]" } as Part;
if (part.type === "tool_result") return { ...part, content: part.content.slice(0, 1500), images: [], meta: undefined } as Part;
return part;
}
export function cacheInfo(info: Info) {
if (!info.user) return;
write({ ...read(), info });
}
export function cachedInfo(): Info | undefined {
return read().info;
}
export function cacheSessions(sessions: Session[]) {
write({ ...read(), sessions });
}
export function cachedSessions(): Session[] {
return read().sessions ?? [];
}
export function cacheChat(session: Session, messages: Message[]) {
const c = read();
c.chats[session.id] = {
at: Date.now(),
session,
messages: messages.slice(-MAX_MESSAGES).map((m) => ({ ...m, parts: m.parts.map(slim) })),
};
const ids = Object.entries(c.chats).sort((a, b) => b[1].at - a[1].at);
for (const [id] of ids.slice(MAX_CHATS)) delete c.chats[id];
write(c);
}
export function cachedChat(id: string) {
return read().chats[id];
}
/** A request failed because there is no connection (as opposed to an answer from the server). */
export function isOffline(e: unknown): boolean {
return e instanceof TypeError || !navigator.onLine;
}
+85
View File
@@ -0,0 +1,85 @@
import { useSyncExternalStore } from "react";
import { ApiError, post } from "./api";
/** Messages written without a connection: kept on this device and sent when it is back. */
export interface OutboxItem {
id: string;
sessionId: string;
text: string;
effort?: string;
createdAt: number;
}
const KEY = "codecraft.outbox";
const listeners = new Set<() => void>();
let cache: OutboxItem[] | undefined;
function load(): OutboxItem[] {
if (cache) return cache;
try {
const v = JSON.parse(localStorage.getItem(KEY) ?? "[]");
cache = Array.isArray(v) ? v : [];
} catch {
cache = [];
}
return cache!;
}
function save(items: OutboxItem[]) {
cache = items;
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
// Storage blocked: the list still works until the page is closed.
}
listeners.forEach((l) => l());
}
export function addToOutbox(sessionId: string, text: string, effort?: string) {
save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]);
}
export function removeFromOutbox(id: string) {
save(load().filter((i) => i.id !== id));
}
export function useOutbox(sessionId: string): OutboxItem[] {
const all = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => load(),
);
return all.filter((i) => i.sessionId === sessionId);
}
/** True for failures of the connection itself (not answers of the server). */
export function isOffline(e: unknown) {
return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError);
}
let flushing = false;
/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */
export async function flushOutbox(sessionId: string): Promise<number> {
if (flushing) return 0;
flushing = true;
let sent = 0;
try {
for (const item of load().filter((i) => i.sessionId === sessionId)) {
try {
await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort });
removeFromOutbox(item.id);
sent++;
} catch (e) {
if (isOffline(e)) break;
// The server refused it (deleted chat, read-only …): it would never go through.
removeFromOutbox(item.id);
}
}
} finally {
flushing = false;
}
return sent;
}
+43 -4
View File
@@ -1,3 +1,4 @@
import { cacheChat, cacheSessions, cachedChat, cachedSessions, isOffline } from "./offline";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { get } from "./api"; import { get } from "./api";
import type { import type {
@@ -9,6 +10,7 @@ import type {
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
ServerEvent, ServerEvent,
TurnCheckInfo,
Session, Session,
} from "./types"; } from "./types";
@@ -28,6 +30,8 @@ export interface State {
/** Newer release (admins). */ /** Newer release (admins). */
update: { version: string; url: string; notes: string } | null; update: { version: string; url: string; notes: string } | null;
connected: boolean; connected: boolean;
/** Showing a saved copy: the server cannot be reached. */
offline: boolean;
sessions: Session[]; sessions: Session[];
/** Includes subagent sessions loaded on demand. */ /** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>; sessionById: Record<string, Session>;
@@ -51,12 +55,15 @@ export interface State {
waiting: Record<string, string | undefined>; waiting: Record<string, string | undefined>;
/** Compaction running in a session (auto = context nearly full). */ /** Compaction running in a session (auto = context nearly full). */
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>; compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
/** Check results per session, by the id of the turn's last answer. */
checks: Record<string, Record<string, TurnCheckInfo> | undefined>;
} }
let state: State = { let state: State = {
me: null, me: null,
update: null, update: null,
connected: false, connected: false,
offline: false,
sessions: [], sessions: [],
sessionById: {}, sessionById: {},
messages: {}, messages: {},
@@ -73,6 +80,7 @@ let state: State = {
steps: {}, steps: {},
waiting: {}, waiting: {},
compacting: {}, compacting: {},
checks: {},
}; };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -203,6 +211,11 @@ export function applyEvent(ev: ServerEvent) {
}, },
})); }));
break; break;
case "check_result": {
const info: TurnCheckInfo = { message_id: ev.message_id, status: ev.status, exit_code: ev.exit_code, duration_ms: ev.duration_ms, output: ev.output };
setState((s) => ({ checks: { ...s.checks, [ev.session_id]: { ...s.checks[ev.session_id], [ev.message_id]: info } } }));
break;
}
case "mcp_status": case "mcp_status":
setState(() => ({ mcp: ev.servers })); setState(() => ({ mcp: ev.servers }));
break; break;
@@ -227,7 +240,17 @@ export function dismissError(sessionId: string, id: number) {
} }
export async function loadSessions() { export async function loadSessions() {
const list = await get<Session[]>("/sessions"); let list: Session[];
try {
list = await get<Session[]>("/sessions");
cacheSessions(list);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
if (!isOffline(e)) throw e;
list = cachedSessions().map((x) => ({ ...x, busy: false }));
setState(() => ({ offline: true }));
if (list.length === 0) throw e;
}
setState((s) => ({ setState((s) => ({
sessions: list, sessions: list,
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) }, sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
@@ -241,9 +264,19 @@ export const PAGE = 200;
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean }; type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
export async function loadSession(id: string) { export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>( type Loaded = { session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean };
`/sessions/${id}?limit=${PAGE}`, let data: Loaded;
); try {
data = await get<Loaded>(`/sessions/${id}?limit=${PAGE}`);
cacheChat(data.session, data.messages);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
const copy = isOffline(e) ? cachedChat(id) : undefined;
if (!copy) throw e;
// No connection: show the saved copy; new messages wait in the outbox.
data = { session: copy.session, messages: copy.messages, busy: false, can_write: true, queue: [], has_older: true };
setState(() => ({ offline: true }));
}
setState((s) => ({ setState((s) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } }, windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session), ...upsertSession(s, data.session),
@@ -259,6 +292,12 @@ export async function loadSession(id: string) {
})); }));
} }
/** Check results of a chat (badges on its turns). */
export async function loadChecks(id: string) {
const list = await get<TurnCheckInfo[]>(`/sessions/${id}/checks`).catch(() => [] as TurnCheckInfo[]);
setState((s) => ({ checks: { ...s.checks, [id]: { ...Object.fromEntries(list.map((c) => [c.message_id, c])), ...s.checks[id] } } }));
}
/** Older messages before the loaded ones (scrolling up). Returns how many came. */ /** Older messages before the loaded ones (scrolling up). Returns how many came. */
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> { export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
const first = getState().messages[id]?.[0]; const first = getState().messages[id]?.[0];
+592 -11
View File
@@ -439,6 +439,9 @@ button.session-group:hover {
.main { .main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
/* Nothing in the bars may widen the page (and push it sideways on phones). */
overflow-x: clip;
max-width: 100vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -518,16 +521,90 @@ button.session-group:hover {
.msg { .msg {
margin-bottom: 14px; margin-bottom: 14px;
position: relative;
} }
/* Message actions: hover on desktop, faintly visible on touch. */
/* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
* the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
.msg-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.msg-foot .msg-time {
margin-top: 0;
flex: 1;
}
.msg-menu.menu-pop {
position: static; /* the popup is placed against the bubble */
flex-shrink: 0;
opacity: 0.45;
transition: opacity 0.12s;
}
.msg:hover .msg-menu,
.msg-menu:focus-within,
.msg-menu[open] {
opacity: 1;
}
.msg-menu.menu-pop > summary {
padding: 0 4px;
border-radius: 6px;
line-height: 0;
background: transparent;
border: none;
}
.msg-menu.menu-pop > summary:hover {
background: color-mix(in srgb, var(--fg1) 14%, transparent);
}
.msg-menu.menu-pop .menu-pop-body {
top: auto;
bottom: 30px;
right: 8px;
z-index: 30;
min-width: 0;
width: max-content;
max-width: max(calc(100% - 16px), 11rem);
}
.msg-menu.menu-pop .menu-pop-body a {
white-space: normal;
}
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
.msg-user { .msg-user {
background: var(--bg1); background: color-mix(in srgb, var(--accent) 26%, var(--bg1));
border: 1px solid var(--bg2); border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-radius: var(--radius); border-radius: 18px 4px 18px 18px;
padding: 8px 12px; color: var(--fg0);
padding: 9px 14px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
position: relative; position: relative;
} }
.msg.user-msg {
width: fit-content;
max-width: min(88%, 780px);
margin-left: auto;
}
.msg.bubble.agent {
width: fit-content;
max-width: 100%;
padding: 10px 14px;
background: var(--bg1);
border: 1px solid var(--bg2);
border-radius: 4px 18px 18px 18px;
}
.msg.bubble.agent > :last-child {
margin-bottom: 0;
}
.chat-pane[data-color] .msg-user {
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
}
@media (max-width: 700px) {
.msg.user-msg {
max-width: 94%;
}
}
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */ /* User messages are Markdown too; the line breaks come from keepLineBreaks. */
.msg-user .markdown { .msg-user .markdown {
white-space: normal; white-space: normal;
@@ -542,14 +619,16 @@ button.session-group:hover {
background: var(--bg1); background: var(--bg1);
cursor: text; cursor: text;
} }
.msg-user .msg-actions { .msg-time {
position: absolute;
top: 4px;
right: 4px;
display: none;
}
.msg-user:hover .msg-actions {
display: block; display: block;
margin-top: 4px;
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.msg-user .msg-foot .msg-time {
text-align: right;
} }
.msg-user img { .msg-user img {
max-height: 160px; max-height: 160px;
@@ -1357,6 +1436,23 @@ table.grid input[type="number"] {
.topbar .path { .topbar .path {
display: none; display: none;
} }
.topbar .grow {
min-width: 0;
}
.topbar .chip {
flex-shrink: 1;
min-width: 0;
max-width: 30vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-pop-body {
max-width: calc(100vw - 16px);
}
.menu-pop-body .mobile-only {
display: block;
}
.topbar button.small { .topbar button.small {
padding: 2px 5px; padding: 2px 5px;
} }
@@ -2526,6 +2622,7 @@ button.danger-solid {
height: 0; height: 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px;
pointer-events: none; pointer-events: none;
z-index: 4; z-index: 4;
} }
@@ -4439,3 +4536,487 @@ span.chat-tab > button {
height: 64px; height: 64px;
color: var(--accent); color: var(--accent);
} }
/* --- image viewer ------------------------------------------------------------ */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.viewer-img {
max-width: 100vw;
max-height: 100dvh;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
transition: transform 0.08s ease-out;
background: repeating-conic-gradient(#222 0% 25%, #111 0% 50%) 50% / 16px 16px;
}
.viewer-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
padding-top: max(8px, env(safe-area-inset-top));
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
color: #eee;
font-size: 13px;
}
.viewer-bar .button,
.viewer-bar button {
color: #eee;
}
.viewer-count {
font-variant-numeric: tabular-nums;
min-width: 3.5em;
}
.viewer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.viewer-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1;
width: 40px;
height: 56px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: #eee;
}
.viewer-nav.prev {
left: 6px;
}
.viewer-nav.next {
right: 6px;
}
@media (pointer: coarse) {
.viewer-nav {
display: none;
}
}
/* Thumbnails invite a tap. */
.chat img.inline-image,
.image-refs img,
.user-files img,
.image-result img,
.svg-block img {
cursor: zoom-in;
}
/* --- image markup ------------------------------------------------------------ */
.annotator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 52px 8px 64px;
}
.annotator canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
touch-action: none;
cursor: crosshair;
background: #111;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.annotator-tools {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 8px 10px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.75);
}
.annotator-colors {
display: inline-flex;
gap: 6px;
margin: 0 6px;
}
.annotator-colors .swatch {
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
}
.annotator-colors .swatch.on {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
}
/* --- approvals: explanation, session summary ------------------------------------ */
.explain {
margin: 8px 0 0;
padding: 6px 10px;
border-left: 3px solid var(--purple);
border-radius: 4px;
background: var(--bg1);
font-size: 12.5px;
color: var(--fg2);
}
.explain .icon {
color: var(--purple);
}
.explain-btn {
margin-top: 6px;
}
.session-item .summary {
font-size: 12px;
color: var(--fg3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- changes of one turn ------------------------------------------------------- */
.turn-changes {
margin: -6px 0 12px;
}
.turn-changes-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
padding: 6px 8px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
}
.turn-changes-body > button {
align-self: flex-start;
}
.turn-file > summary {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
}
.turn-file .turn-stat {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.turn-file .turn-stat .add {
color: var(--green);
}
.turn-file .turn-stat .del {
color: var(--red);
}
.turn-file .diff {
max-height: 50vh;
overflow: auto;
}
/* --- colour tags of chats ------------------------------------------------------
* The tag is a name. Eight clearly separated hues, drawn with a lightness that fits the theme
* (light or dark). Browsers without relative colours use the theme's own colours.
* Backgrounds are mixed into --bg0, so text stays readable. */
[data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; }
[data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; }
[data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; }
[data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; }
[data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; }
[data-color="blue"] { --tag: var(--blue); --tag-h: 250; --tag-c: 0.12; }
[data-color="purple"] { --tag: var(--purple); --tag-h: 297; --tag-c: 0.135; }
[data-color="pink"] { --tag: var(--red); --tag-h: 338; --tag-c: 0.17; }
@supports (color: oklch(from red l c h)) {
/* Lightness 0.76 on dark and 0.60 on light themes (from the theme's background), chroma per hue
* as high as sRGB allows at both – vivid and still clearly different from each other. */
[data-color] {
--tag: oklch(from var(--bg0) calc(0.6 + 0.16 * clamp(0, (0.5 - l) * 20, 1)) var(--tag-c) var(--tag-h));
}
}
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
* the chat background itself stays neutral. */
.topbar[data-color] {
border-bottom: 3px solid var(--tag);
}
.session-item[data-color] {
box-shadow: inset 4px 0 0 var(--tag);
}
.color-badge {
padding: 0 8px;
border-radius: 999px;
color: var(--fg0);
background: color-mix(in srgb, var(--tag) 42%, var(--bg1));
border: 1px solid color-mix(in srgb, var(--tag) 75%, transparent);
}
.chat-icon {
margin-right: 5px;
vertical-align: -2px;
}
[data-color] .chat-icon {
color: var(--tag);
}
.color-badge .chat-icon {
color: var(--fg0);
}
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 10px;
}
.color-swatches .swatch {
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
background: var(--tag);
border: 2px solid transparent;
}
.color-swatches .swatch.on {
border-color: var(--fg0);
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
}
.icon-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
padding: 4px 10px 8px;
max-width: 280px;
}
.icon-choice {
padding: 5px;
line-height: 0;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--fg2);
}
.icon-choice:hover {
background: var(--bg2);
}
.icon-choice.on {
color: var(--tag, var(--accent));
border-color: var(--tag, var(--accent));
background: var(--bg2);
}
/* --- check badge of a turn ------------------------------------------------------ */
.turn-check {
margin: -6px 0 12px;
}
.turn-check .chip {
border: none;
cursor: pointer;
}
.turn-check .check-passed {
color: var(--green);
}
.turn-check .check-failed,
.turn-check .check-timeout {
color: var(--red);
}
.turn-check-body {
margin-top: 4px;
padding: 6px 8px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
}
.turn-check-body pre {
margin: 0 0 6px;
max-height: 40vh;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
}
/* --- messages waiting for a connection ------------------------------------------ */
.outbox {
margin: 0 12px 6px;
padding: 6px 10px;
border: 1px dashed var(--accent2, var(--accent));
border-radius: var(--radius);
background: var(--bg1);
font-size: 12.5px;
}
.outbox-head,
.outbox-item {
display: flex;
align-items: center;
gap: 8px;
}
.outbox-item span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg3);
}
/* --- project page: timeline -------------------------------------------------------- */
.project-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.timeline-day h3 {
margin: 14px 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.timeline {
list-style: none;
margin: 0;
padding: 0 0 0 14px;
border-left: 2px solid var(--bg3);
}
.timeline li {
position: relative;
margin: 0 0 8px;
}
.timeline li::before {
content: "";
position: absolute;
left: -20px;
top: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--tag, var(--bg3));
border: 2px solid var(--bg0);
}
.timeline li > a {
display: block;
padding: 8px 12px;
border: 1px solid var(--bg2);
border-radius: var(--radius);
background: var(--bg1);
color: var(--fg1);
text-decoration: none;
}
.timeline li > a:hover {
border-color: var(--bg3);
}
.tl-title {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--fg0);
font-weight: 600;
}
.tl-when {
margin-left: auto;
font-weight: 400;
font-size: 11.5px;
color: var(--muted);
}
.tl-summary {
margin-top: 2px;
color: var(--fg2);
font-size: 13px;
}
.tl-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 4px;
font-size: 11.5px;
color: var(--muted);
}
.tl-meta .check-passed {
color: var(--green);
}
.tl-meta .check-failed,
.tl-meta .check-timeout {
color: var(--red);
}
/* Foldable statistics sections */
.fold-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.fold-caret {
width: 0;
height: 0;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
opacity: 0.7;
}
.fold-toggle[aria-expanded="true"] .fold-caret {
transform: rotate(90deg);
}
.panel.fold.closed h3 {
margin-bottom: 0;
}
.fold-group td {
background: var(--bg2, color-mix(in srgb, var(--fg, #888) 6%, transparent));
font-weight: 600;
}
td.fold-indent {
padding-left: 1.6em;
}
.snippet-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.snippet-slash {
color: var(--muted);
font-family: var(--mono, monospace);
}
.public-chat { max-width: 820px; margin: 0 auto; padding: 16px; }
.public-chat header { margin-bottom: 12px; }
.public-chat h1 { font-size: 1.2rem; margin: 0 0 4px; }
.public-chat .chat-scroll { display: flex; flex-direction: column; gap: 10px; }
.share-links { margin-top: 14px; border-top: 1px solid var(--border, #8884); padding-top: 10px; }
.share-links h4 { margin: 0 0 4px; }
.share-link { margin-top: 6px; flex-wrap: wrap; }
.share-link input { flex: 1 1 200px; min-width: 0; }
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ssh-key .row { align-items: center; flex-wrap: wrap; }
.ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
+17
View File
@@ -59,6 +59,12 @@ export interface Session {
parent_id?: string; parent_id?: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
/** One line "where things stand" (small model), for the session list. */
summary?: string;
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
color?: string;
/** Icon name of the chat (from the picker). */
icon?: string;
todos: Todo[] | null; todos: Todo[] | null;
last_usage?: Usage | null; last_usage?: Usage | null;
busy?: boolean; busy?: boolean;
@@ -112,6 +118,16 @@ export interface QuestionRequest {
questions: Question[]; questions: Question[];
} }
/** Result of the project's check command after a turn. */
export type CheckStatus = "running" | "passed" | "failed" | "timeout";
export interface TurnCheckInfo {
message_id: string;
status: CheckStatus;
exit_code: number | null;
duration_ms: number;
output: string;
}
export type ServerEvent = export type ServerEvent =
| { type: "session_updated"; session: Session } | { type: "session_updated"; session: Session }
| { type: "session_deleted"; session_id: string } | { type: "session_deleted"; session_id: string }
@@ -129,6 +145,7 @@ export type ServerEvent =
| { type: "waiting"; session_id: string; reason: string | null } | { type: "waiting"; session_id: string; reason: string | null }
| { type: "queue_updated"; session_id: string; items: QueueItem[] } | { type: "queue_updated"; session_id: string; items: QueueItem[] }
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean } | { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
| { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string }
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null } | { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
| { type: "mcp_status"; servers: Record<string, McpServerStatus> } | { type: "mcp_status"; servers: Record<string, McpServerStatus> }
| { type: "resync" }; | { type: "resync" };
+72
View File
@@ -3,6 +3,78 @@
* 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-00040": {
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
},
"202611-00039": {
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
},
"202611-00038": {
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
},
"202611-00037": {
en: ["Deleting a chat now asks whether to delete its own folder too"],
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
},
"202611-00036": {
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
},
"202611-00035": {
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
},
"202611-00034": {
en: ["ssh/scp/sftp now work in the container (fixes \"No user exists for uid\")"],
de: ["ssh/scp/sftp funktionieren jetzt im Container (behebt \"No user exists for uid\")"],
},
"202611-00033": {
en: ["SSH keys in Settings → Account: agents, terminal and git use them to reach other servers"],
de: ["SSH-Schlüssel unter Einstellungen → Account: Agenten, Terminal und Git nutzen sie für den Zugriff auf andere Server"],
},
"202611-00032": {
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
},
"202611-00031": {
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
},
"202611-00030": {
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
},
"202611-00029": {
en: [
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
"Prompt snippets (/name), read-only links for chats, commit message suggestions, weekly review, offline copies of recent chats, planning model",
],
de: [
"Statistik: zuklappbare Abschnitte, Historie über Jahre mit Jahresfilter, Export als XLSX/CSV",
"Prompt-Bausteine (/name), Nur-Lese-Links für Chats, Commit-Vorschläge, Wochenrückblick, Offline-Kopien der letzten Chats, Planungsmodell",
],
},
"202611-00028": {
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
},
"202611-00027": {
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],
},
"202611-00026": {
en: [
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
"Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page",
],
de: [
"Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen",
"Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite",
],
},
"202611-00024": { "202611-00024": {
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"], en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"], de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],