Compare commits

..
20 changed files with 2805 additions and 3 deletions

No files matched your search

+3
View File
@@ -9,6 +9,9 @@ CODECRAFT_ADMIN_PASSWORD=
# Directory with your projects, mounted as /workspace.
WORKSPACE=/path/to/your/projects
# Directory of the software factory (clones it works in), mounted as /factory.
FACTORY_DIR=./factory
# Host port and address (127.0.0.1 = only reachable through a local reverse proxy).
PORT=8787
BIND_ADDR=127.0.0.1
+29
View File
@@ -3,6 +3,35 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00041] – 2026-11-15
### Added
- **Software factory** (sidebar → Factory): specs arrive as issues (label `factory`) on Gitea, GitHub or
GitLab and are worked off by agents in phases, each phase with its own model, agent, permission mode
and time limit. This version contains the **review phase**: an agent reads the spec and the code and
decides *approved*, *questions* (posted as an issue comment; your answer restarts the review) or
*rejected*, and writes acceptance scenarios that stay in the database, away from the builder. Build and
acceptance follow.
- **Connections per user**: every user sets up their own accounts (Gitea, GitHub, GitLab) with their own
token; repositories use the token of their connection or one of their own. Models are free per user,
project and phase.
- **New project with a model**: *Factory → New project* opens a spec chat. When the spec is done, the
*Create project* bar picks name, connection and owner, creates the repository (private), commits the
spec as `docs/specs/<name>.md` and opens the issue that starts the workflow.
- **Overview**: board with all issues by state, a tile per project, and per run the model, status,
duration, tokens and cost, with a link into the chat of that run.
- **Separate factory area**: repositories are cloned into their own mount `/factory` (`FACTORY_DIR` in
`.env`), not into the workspace. The API token is handed to git only as a header and never reaches
agents.
## [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
+1 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00039" src="https://img.shields.io/badge/version-v202611--00039-d69e2e?style=flat-square">
<img alt="Version v202611-00041" src="https://img.shields.io/badge/version-v202611--00041-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+2
View File
@@ -34,6 +34,8 @@ services:
- codecraft-data:/data
# The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
+2
View File
@@ -30,6 +30,8 @@ services:
- codecraft-data:/data
# The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
+134
View File
@@ -159,6 +159,14 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/factory", get(get_factory).put(put_factory))
.route("/account/factory/test", post(test_factory))
.route("/factory/overview", get(factory_overview))
.route("/factory/poll", post(factory_poll))
.route("/account/factory/connection/test", post(test_factory_connection))
.route("/factory/drafts", post(factory_new_draft))
.route("/factory/drafts/{id}", get(factory_draft))
.route("/factory/drafts/{id}/create", post(factory_create_project))
.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))
@@ -2584,6 +2592,132 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!(crate::gitcreds::masked(&new))))
}
// --- software factory ---------------------------------------------------------------
async fn get_factory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
Ok(Json(json!(crate::factory::masked(&s))))
}
async fn put_factory(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::factory::FactorySettings>) -> ApiResult {
let old: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut new = crate::factory::merge(&old, b);
for r in &mut new.repos {
r.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{}: {e:#}", r.url)))?;
}
app.store.set_user_setting(&user.id, "factory", &new)?;
let urls: Vec<&str> = new.repos.iter().map(|r| r.url.as_str()).collect();
app.audit(Some(&user), "factory.settings", urls.join(", "), &crate::auth::client_ip(&headers));
Ok(Json(json!(crate::factory::masked(&new))))
}
#[derive(Deserialize)]
struct FactoryTest {
url: String,
}
/// Check token and repository and create the missing status labels.
async fn test_factory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<FactoryTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let cfg = s.repos.iter().find(|r| r.url == b.url.trim()).map(|r| s.resolve(r)).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the repository first".into()))?;
let cfg = &cfg;
let g = crate::factory::Forge::new(cfg).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let info = g.check().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let created = g.ensure_labels().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let dir = crate::factory::checkout(&app, &user, cfg).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"ok": true, "info": info, "labels_created": created, "folder": dir.display().to_string()})))
}
/// Everything the factory page shows: repositories, issues with state, runs with tokens/cost.
async fn factory_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let repos: Vec<Value> = s.repos.iter().map(|r| json!({"url": r.url, "enabled": r.enabled, "label": r.label, "max_parallel": r.max_parallel, "kind": s.resolve(r).kind, "has_token": !s.resolve(r).token.is_empty(), "models": {"review": r.review.model, "build": r.build.model, "qa": r.qa.model}, "running": app.store.factory_running(&r.url).unwrap_or(0)})).collect();
Ok(Json(json!({
"area": app.factory_dir().is_some(),
"repos": repos,
"issues": app.store.factory_issues(&user.id)?,
"runs": app.store.factory_runs(&user.id, None, None, 300)?,
})))
}
/// Look for new issues right now instead of waiting for the next poll.
async fn factory_poll(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut errors = Vec::new();
for cfg in s.repos.iter().filter(|r| r.enabled).map(|r| s.resolve(r)).filter(|r| !r.token.is_empty()) {
if let Err(e) = crate::factory::poll_repo(&app, &user, &cfg).await {
errors.push(format!("{}: {e:#}", cfg.url));
}
}
Ok(Json(json!({"errors": errors})))
}
#[derive(Deserialize)]
struct ConnTest {
id: String,
}
/// Test a connection: who the token belongs to and which organisations/groups it can use.
async fn test_factory_connection(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ConnTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conn = s.connection(&b.id).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the connection first".into()))?;
let g = crate::factory::Forge::for_conn(conn).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let login = g.login().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let orgs = g.orgs().await.unwrap_or_default();
Ok(Json(json!({"login": login, "orgs": orgs})))
}
#[derive(Deserialize)]
struct NewDraft {
#[serde(default)]
model: Option<String>,
}
/// Start a chat in which the user writes a spec with a model.
async fn factory_new_draft(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewDraft>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let model = b.model.filter(|m| !m.is_empty()).unwrap_or(s.defaults.spec_model.clone());
let session = crate::factory::start_spec_chat(&app, &user, &model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
Ok(Json(json!(session)))
}
fn draft_session(app: &AppState, user: &User, id: &str) -> Result<(crate::types::Session, std::path::PathBuf), ApiError> {
let session = app.session(id).map_err(|_| ApiError(StatusCode::NOT_FOUND, "unknown chat".into()))?;
let dir = crate::factory::draft_of(app, user, &session).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "not a spec chat".into()))?;
Ok((session, dir))
}
/// The state of a spec chat: spec text, the model's name proposal, connections to choose from.
async fn factory_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conns: Vec<Value> = s.connections.iter().map(|c| json!({"id": c.id, "name": c.name, "kind": c.kind, "owner": c.owner})).collect();
Ok(Json(json!({"spec": spec, "proposal": crate::factory::proposal(&app, &id), "connections": conns})))
}
#[derive(Deserialize)]
struct CreateProject {
connection: String,
name: String,
slug: String,
#[serde(default)]
description: String,
#[serde(default)]
owner: String,
}
/// Create the repository, file the spec and open the issue that starts the workflow.
async fn factory_create_project(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<CreateProject>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let mut s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let res = crate::factory::create_project(&app, &user, &mut s, &spec, &b.connection, &b.name, &b.slug, &b.description, &b.owner).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "factory", &s)?;
app.audit(Some(&user), "factory.project.create", res["repo"].as_str().unwrap_or(""), &crate::auth::client_ip(&headers));
Ok(Json(res))
}
// --- web terminal ------------------------------------------------------------------
#[derive(Deserialize)]
+18 -1
View File
@@ -189,6 +189,20 @@ impl App {
std::fs::canonicalize(&ws).unwrap_or(ws)
}
/// Separate area of the software factory (mounted outside the workspace); None when absent.
pub fn factory_dir(&self) -> Option<PathBuf> {
let p = std::env::var("CODECRAFT_FACTORY_DIR").ok().filter(|p| !p.is_empty()).unwrap_or_else(|| "/factory".into());
let p = PathBuf::from(p);
p.is_dir().then(|| std::fs::canonicalize(&p).unwrap_or(p))
}
/// The user's own folder in the factory area (created on demand).
pub fn factory_home(&self, user: &crate::store::User) -> Option<PathBuf> {
let dir = self.factory_dir()?.join(&user.username);
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Directories a session owner may work in: admins (and legacy sessions
/// without owner) the whole workspace, users their own folder plus shared folders.
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
@@ -196,12 +210,15 @@ impl App {
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
return vec![ws];
};
// The user's factory folder (if the area is mounted) is an additional root.
let factory = self.factory_dir().map(|d| d.join(&user.username)).filter(|d| d.is_dir());
if user.is_admin() {
return vec![ws];
return std::iter::once(ws).chain(factory).collect();
}
let own = ws.join(&user.username);
let _ = std::fs::create_dir_all(&own);
let mut roots = vec![own];
roots.extend(factory);
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
let p = PathBuf::from(p);
if p.starts_with(&ws) && p.is_dir() {
+22 -1
View File
@@ -361,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue };
// An image without bytes (an unresolved reference) would become an empty file.
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
if bytes.is_empty() {
tracing::warn!("claude code: image {i} of the message has no data – not saved");
continue;
}
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() {
@@ -379,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let session = app.session(session_id)?;
let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?);
// With the bytes of the images filled in (they are stored as references).
let history = crate::runner::active_history(app.store.messages_for_model(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message");
};
@@ -956,6 +962,21 @@ pub async fn update() -> Result<String> {
#[cfg(test)]
mod tests {
#[test]
fn uploaded_images_are_saved_with_their_bytes_only() {
let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let parts = vec![
Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } },
// A reference whose bytes were not filled in: must not become an empty file.
Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } },
];
let saved = save_images(&dir.display().to_string(), &parts);
assert_eq!(saved.len(), 1);
assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn questions_roundtrip_with_the_cli_format() {
let input = json!({"questions": [
File diff suppressed because it is too large. Load diff
+2
View File
@@ -13,6 +13,7 @@ mod config;
mod digest;
mod docs;
mod export;
mod factory;
mod files;
mod git;
mod health;
@@ -78,6 +79,7 @@ async fn main() -> Result<()> {
push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
factory::spawn(app.clone());
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
digest::spawn(app.clone());
+124
View File
@@ -171,6 +171,39 @@ impl Store {
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS factory_issues (
repo_url TEXT NOT NULL,
number INTEGER NOT NULL,
owner_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'new',
attempts INTEGER NOT NULL DEFAULT 0,
rounds INTEGER NOT NULL DEFAULT 0,
last_comment INTEGER NOT NULL DEFAULT 0,
scenarios TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
pr_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_url, number)
);
CREATE TABLE IF NOT EXISTS factory_runs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
repo_url TEXT NOT NULL,
issue INTEGER NOT NULL,
phase TEXT NOT NULL,
round INTEGER NOT NULL DEFAULT 0,
session_id TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
result TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS factory_runs_issue ON factory_runs(repo_url, issue);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
@@ -1427,6 +1460,97 @@ impl Store {
Ok(())
}
// --- software factory ---------------------------------------------------------
pub fn factory_issue(&self, repo: &str, number: i64) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT title, author, state, attempts, rounds, last_comment, scenarios, branch, pr_url, note, created_at, updated_at, owner_id FROM factory_issues WHERE repo_url = ?1 AND number = ?2", params![repo, number], |r| {
Ok(json!({"repo": repo, "number": number, "title": r.get::<_, String>(0)?, "author": r.get::<_, String>(1)?, "state": r.get::<_, String>(2)?, "attempts": r.get::<_, i64>(3)?, "rounds": r.get::<_, i64>(4)?, "last_comment": r.get::<_, i64>(5)?, "scenarios": r.get::<_, String>(6)?, "branch": r.get::<_, String>(7)?, "pr_url": r.get::<_, String>(8)?, "note": r.get::<_, String>(9)?, "created_at": r.get::<_, i64>(10)?, "updated_at": r.get::<_, i64>(11)?, "owner_id": r.get::<_, String>(12)?}))
})
.optional()?)
}
pub fn factory_issue_add(&self, repo: &str, number: i64, owner: &str, title: &str, author: &str) -> Result<()> {
let now = crate::types::now_ms();
self.conn.lock().unwrap().execute(
"INSERT OR IGNORE INTO factory_issues (repo_url, number, owner_id, title, author, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?6)",
params![repo, number, owner, title, author, now],
)?;
Ok(())
}
/// Update the given columns (whitelisted) of an issue.
pub fn factory_issue_set(&self, repo: &str, number: i64, fields: &[(&str, Value)]) -> Result<()> {
const COLS: [&str; 10] = ["title", "state", "attempts", "rounds", "last_comment", "scenarios", "branch", "pr_url", "note", "author"];
let conn = self.conn.lock().unwrap();
for (k, v) in fields {
if !COLS.contains(k) {
anyhow::bail!("unknown column {k}");
}
let sql = format!("UPDATE factory_issues SET {k} = ?3, updated_at = ?4 WHERE repo_url = ?1 AND number = ?2");
let now = crate::types::now_ms();
match v {
Value::Number(n) => conn.execute(&sql, params![repo, number, n.as_i64().unwrap_or(0), now])?,
other => conn.execute(&sql, params![repo, number, other.as_str().unwrap_or(""), now])?,
};
}
Ok(())
}
pub fn factory_issues(&self, owner: &str) -> Result<Vec<Value>> {
let nums: Vec<(String, i64)> = {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare("SELECT repo_url, number FROM factory_issues WHERE owner_id = ?1 ORDER BY updated_at DESC")?;
st.query_map([owner], |r| Ok((r.get(0)?, r.get(1)?)))?.filter_map(|r| r.ok()).collect()
};
Ok(nums.into_iter().filter_map(|(r, n)| self.factory_issue(&r, n).ok().flatten()).collect())
}
pub fn factory_run_add(&self, id: &str, owner: &str, repo: &str, issue: i64, phase: &str, round: i64, session: &str, model: &str) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO factory_runs (id, owner_id, repo_url, issue, phase, round, session_id, model, started_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
params![id, owner, repo, issue, phase, round, session, model, crate::types::now_ms()],
)?;
Ok(())
}
pub fn factory_run_end(&self, id: &str, status: &str, result: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE factory_runs SET ended_at = ?2, status = ?3, result = ?4 WHERE id = ?1", params![id, crate::types::now_ms(), status, result])?;
Ok(())
}
pub fn factory_running(&self, repo: &str) -> Result<i64> {
Ok(self.conn.lock().unwrap().query_row("SELECT COUNT(*) FROM factory_runs WHERE repo_url = ?1 AND status = 'running'", [repo], |r| r.get(0))?)
}
/// Runs with token and cost totals (joined from the usage table).
pub fn factory_runs(&self, owner: &str, repo: Option<&str>, issue: Option<i64>, limit: i64) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare(
"SELECT f.id, f.repo_url, f.issue, f.phase, f.round, f.session_id, f.model, f.started_at, f.ended_at, f.status, f.result,
COALESCE((SELECT SUM(input_tokens + cache_read_tokens + cache_write_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(output_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(cost) FROM usage WHERE session_id = f.session_id),0.0)
FROM factory_runs f WHERE f.owner_id = ?1 AND (?2 IS NULL OR f.repo_url = ?2) AND (?3 IS NULL OR f.issue = ?3)
ORDER BY f.started_at DESC LIMIT ?4",
)?;
let rows = st.query_map(params![owner, repo, issue, limit], |r| {
Ok(json!({"id": r.get::<_, String>(0)?, "repo": r.get::<_, String>(1)?, "issue": r.get::<_, i64>(2)?, "phase": r.get::<_, String>(3)?, "round": r.get::<_, i64>(4)?, "session_id": r.get::<_, String>(5)?, "model": r.get::<_, String>(6)?, "started_at": r.get::<_, i64>(7)?, "ended_at": r.get::<_, Option<i64>>(8)?, "status": r.get::<_, String>(9)?, "result": r.get::<_, String>(10)?, "input_tokens": r.get::<_, i64>(11)?, "output_tokens": r.get::<_, i64>(12)?, "cost": r.get::<_, f64>(13)?}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// After a restart: runs that were still going are over; their issues go back one step.
pub fn factory_recover(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute("UPDATE factory_runs SET status = 'interrupted', ended_at = ?1 WHERE status = 'running'", [crate::types::now_ms()])?;
conn.execute("UPDATE factory_issues SET state = 'new' WHERE state = 'review'", [])?;
conn.execute("UPDATE factory_issues SET state = 'approved' WHERE state = 'building'", [])?;
conn.execute("UPDATE factory_issues SET state = 'built' WHERE state = 'qa'", [])?;
Ok(n)
}
pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
Ok(())
+2
View File
@@ -13,6 +13,7 @@ import { NewSession } from "./components/NewSession";
import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Factory } from "./components/Factory";
import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity";
@@ -110,6 +111,7 @@ export function App() {
);
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 === "factory") content = <Factory onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats 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)} />;
+2
View File
@@ -1,3 +1,4 @@
import { DraftBar, isDraftChat } from "./FactoryDraft";
import { takePrefill } from "../share";
import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
@@ -1089,6 +1090,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && isDraftChat(session.cwd) && <DraftBar sessionId={sessionId} />}
{!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useState } from "react";
import { get, post, put } from "../api";
import { fmtCompact, fmtCost, fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { ModelSelect } from "./ModelSelect";
type Phase = "review" | "build" | "qa";
const PHASES: Phase[] = ["review", "build", "qa"];
interface PhaseCfg {
model: string;
agent: string;
permission_mode: string;
timeout_minutes: number;
extra_prompt: string;
}
interface RepoCfg {
url: string;
kind: string;
connection: string;
token: string;
label: string;
max_parallel: number;
allowed_users: string[];
enabled: boolean;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Connection {
id: string;
name: string;
kind: "gitea" | "github" | "gitlab";
url: string;
token: string;
owner: string;
}
interface Defaults {
spec_model: string;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Settings {
connections: Connection[];
defaults: Defaults;
repos: RepoCfg[];
}
interface Issue {
repo: string;
number: number;
title: string;
author: string;
state: string;
note: string;
pr_url: string;
rounds: number;
created_at: number;
updated_at: number;
}
interface Run {
id: string;
repo: string;
issue: number;
phase: Phase;
round: number;
session_id: string;
model: string;
started_at: number;
ended_at: number | null;
status: string;
input_tokens: number;
output_tokens: number;
cost: number;
}
interface RepoInfo {
url: string;
kind: string;
enabled: boolean;
label: string;
max_parallel: number;
has_token: boolean;
models: Record<Phase, string>;
running: number;
}
interface Overview {
area: boolean;
repos: RepoInfo[];
issues: Issue[];
runs: Run[];
}
/** Board columns: a column collects one or more issue states. */
const COLUMNS: { id: string; states: string[] }[] = [
{ id: "review", states: ["new", "review"] },
{ id: "question", states: ["question"] },
{ id: "approved", states: ["approved"] },
{ id: "build", states: ["building", "built"] },
{ id: "qa", states: ["qa"] },
{ id: "done", states: ["done"] },
{ id: "failed", states: ["failed", "rejected"] },
];
const KIND_NAME: Record<string, string> = { gitea: "Gitea", github: "GitHub", gitlab: "GitLab" };
const repoName = (url: string) => url.replace(/\.git$/, "").split("/").slice(-2).join("/");
const issueUrl = (repo: string, n: number) => `${repo.replace(/\.git$/, "")}/issues/${n}`;
function fmtDuration(ms: number) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
}
const runMs = (r: Run, now: number) => (r.ended_at ?? now) - r.started_at;
/** Software factory: spec issues in Gitea are reviewed, built and accepted by agents with a model per phase. */
export function Factory({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [tab, setTab] = useState<"overview" | "settings">("overview");
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("factory.title")}</h1>
</div>
<div className="segmented">
{(["overview", "settings"] as const).map((x) => (
<button key={x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
{t(`factory.tab.${x}` as any)}
</button>
))}
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 1280 }}>
{tab === "overview" ? <FactoryOverview onSettings={() => setTab("settings")} /> : <FactorySettings />}
</div>
</div>
</>
);
}
function FactoryOverview({ onSettings }: { onSettings: () => void }) {
const t = useT();
const [data, setData] = useState<Overview>();
const [repo, setRepo] = useState<string>("");
const [sel, setSel] = useState<string>("");
const [now, setNow] = useState(Date.now());
const [busy, setBusy] = useState(false);
const reload = () =>
get<Overview>("/factory/overview")
.then(setData)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(() => {
void reload();
setNow(Date.now());
}, 5000);
return () => clearInterval(h);
}, []);
const newProject = async () => {
setBusy(true);
try {
const s = await post<{ id: string }>("/factory/drafts", {});
navigate(`/s/${s.id}`);
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
const issues = useMemo(() => (data?.issues ?? []).filter((i) => !repo || i.repo === repo), [data, repo]);
const runs = useMemo(() => (data?.runs ?? []).filter((r) => !repo || r.repo === repo), [data, repo]);
if (!data) return <div className="empty"><Spinner /></div>;
if (data.repos.length === 0)
return (
<div className="empty">
<p>{t("factory.empty")}</p>
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
<button className="primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button onClick={onSettings}>{t("factory.tab.settings")}</button>
</div>
</div>
);
const sum = (f: (r: Run) => number) => runs.reduce((a, r) => a + f(r), 0);
const finished = runs.filter((r) => r.ended_at);
const running = runs.filter((r) => !r.ended_at);
const done = issues.filter((i) => i.state === "done").length;
const failed = issues.filter((i) => ["failed", "rejected"].includes(i.state)).length;
const runsOf = (i: Issue) => data.runs.filter((r) => r.repo === i.repo && r.issue === i.number);
const selected = issues.find((i) => `${i.repo}#${i.number}` === sel);
const shown = selected ? runsOf(selected) : runs.slice(0, 12);
const poll = async () => {
setBusy(true);
try {
const r = await post<{ errors: string[] }>("/factory/poll", {});
if (r.errors.length) toast(r.errors.join("\n"));
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<>
{!data.area && <div className="notice err">{t("factory.noArea")}</div>}
<div className="kpis">
<div className="kpi">
<div className="k-label">{t("factory.kpi.running")}</div>
<div className="k-value">{running.length}</div>
<div className="k-sub">{t("factory.kpi.issues", { n: issues.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.done")}</div>
<div className="k-value">{done}</div>
<div className="k-sub">{t("factory.kpi.failed", { n: failed })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.tokens")}</div>
<div className="k-value">{fmtCompact(sum((r) => r.input_tokens + r.output_tokens))}</div>
<div className="k-sub">
{fmtCompact(sum((r) => r.input_tokens))} {t("factory.in")} · {fmtCompact(sum((r) => r.output_tokens))} {t("factory.out")}
</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.cost")}</div>
<div className="k-value">{fmtCost(sum((r) => r.cost), "USD")}</div>
<div className="k-sub">{t("factory.kpi.runs", { n: runs.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.time")}</div>
<div className="k-value">{finished.length ? fmtDuration(sum((r) => runMs(r, now)) / finished.length) : "–"}</div>
<div className="k-sub">{t("factory.kpi.perRun")}</div>
</div>
</div>
<div className="factory-repos">
<button className={`factory-repo ${repo === "" ? "active" : ""}`} onClick={() => setRepo("")}>
<b>{t("factory.allRepos")}</b>
<span className="hint">{t("factory.kpi.issues", { n: data.issues.length })}</span>
</button>
{data.repos.map((r) => {
const mine = data.issues.filter((i) => i.repo === r.url);
return (
<button key={r.url} className={`factory-repo ${repo === r.url ? "active" : ""} ${r.enabled ? "" : "off"}`} onClick={() => setRepo(r.url)}>
<b>{repoName(r.url)}</b>
<span className="chip">{KIND_NAME[r.kind] ?? r.kind}</span>
<span className="hint">
{r.running > 0 && <span className="factory-pulse" />}
{t("factory.repoLine", { n: mine.length, run: r.running })}
</span>
<span className="hint mono">{PHASES.map((p) => r.models[p] || "·").map((m) => m.split("/").pop()).join(" → ")}</span>
{!r.has_token && <span className="chip warn">{t("factory.noToken")}</span>}
</button>
);
})}
<button className="small primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button className="small" onClick={poll} disabled={busy} title={t("factory.pollNow")}>
<Icon name="refresh" /> {t("factory.pollNow")}
</button>
</div>
<div className="factory-board">
{COLUMNS.map((c) => {
const cards = issues.filter((i) => c.states.includes(i.state));
return (
<div key={c.id} className="factory-col">
<h4>
{t(`factory.col.${c.id}` as any)} <span className="chip">{cards.length}</span>
</h4>
{cards.map((i) => {
const rs = runsOf(i);
const active = rs.find((r) => !r.ended_at);
const latest = active ?? rs[0];
const tokens = rs.reduce((a, r) => a + r.input_tokens + r.output_tokens, 0);
return (
<button key={`${i.repo}#${i.number}`} className={`factory-card ${sel === `${i.repo}#${i.number}` ? "active" : ""} ${active ? "running" : ""}`} onClick={() => setSel(sel === `${i.repo}#${i.number}` ? "" : `${i.repo}#${i.number}`)}>
<span className="hint">
{repoName(i.repo)} #{i.number}
</span>
<b>{i.title || `#${i.number}`}</b>
{latest && (
<span className="hint">
{active && <span className="factory-pulse" />}
{t(`factory.phase.${latest.phase}` as any)} · {(latest.model || "").split("/").pop()} · {fmtDuration(runMs(latest, now))}
</span>
)}
{tokens > 0 && <span className="hint">{fmtCompact(tokens)} {t("factory.tokens")}</span>}
</button>
);
})}
</div>
);
})}
</div>
<div className="panel" style={{ marginTop: 16 }}>
<div className="row">
<h3 style={{ margin: 0 }}>{selected ? `${repoName(selected.repo)} #${selected.number} – ${selected.title}` : t("factory.latestRuns")}</h3>
<span className="spacer" />
{selected && (
<>
<a className="small ghost button-link" href={issueUrl(selected.repo, selected.number)} target="_blank" rel="noreferrer">
<Icon name="external" /> Gitea
</a>
{selected.pr_url && (
<a className="small ghost button-link" href={selected.pr_url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("factory.pr")}
</a>
)}
</>
)}
</div>
{selected?.note && <div className="hint" style={{ margin: "6px 0" }}>{selected.note}</div>}
{shown.length === 0 && <div className="hint">{t("factory.noRuns")}</div>}
{shown.length > 0 && (
<div className="table-wrap">
<table className="grid factory-runs">
<thead>
<tr>
{!selected && <th>{t("factory.issue")}</th>}
<th>{t("factory.phase")}</th>
<th>{t("factory.model")}</th>
<th>{t("factory.status")}</th>
<th>{t("factory.started")}</th>
<th>{t("factory.duration")}</th>
<th>{t("factory.tokens")}</th>
<th>{t("factory.cost")}</th>
<th />
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id}>
{!selected && (
<td>
{repoName(r.repo)} #{r.issue}
</td>
)}
<td>
{t(`factory.phase.${r.phase}` as any)}
{r.round > 0 && <span className="hint"> · {t("factory.round", { n: r.round })}</span>}
</td>
<td className="mono">{r.model}</td>
<td>
<span className={`chip ${r.status === "done" ? "ok" : r.status === "running" ? "" : "warn"}`}>
{r.status === "running" && <span className="factory-pulse" />}
{t(`factory.runStatus.${r.status}` as any) || r.status}
</span>
</td>
<td>{fmtRelative(r.started_at)}</td>
<td>{fmtDuration(runMs(r, now))}</td>
<td title={`${r.input_tokens} / ${r.output_tokens}`}>
{fmtCompact(r.input_tokens)} / {fmtCompact(r.output_tokens)}
</td>
<td>{fmtCost(r.cost, "USD")}</td>
<td>
<button className="small" onClick={() => navigate(`/s/${r.session_id}`)}>
<Icon name="eye" /> {t("factory.openChat")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
const blankPhase = (): PhaseCfg => ({ model: "", agent: "codecraft", permission_mode: "auto", timeout_minutes: 60, extra_prompt: "" });
const blankRepo = (connection = "", kind = "gitea"): RepoCfg => ({ url: "", kind, connection, token: "", label: "factory", max_parallel: 1, allowed_users: [], enabled: true, review: blankPhase(), build: blankPhase(), qa: blankPhase() });
const blankConn = (): Connection => ({ id: "", name: "", kind: "gitea", url: "", token: "", owner: "" });
const MODES = ["default", "auto", "accept_edits", "bypass"];
function PhaseForm({ phase, value, onChange }: { phase: Phase; value: PhaseCfg; onChange: (patch: Partial<PhaseCfg>) => void }) {
const t = useT();
return (
<div className="factory-phase">
<h4>{t(`factory.phase.${phase}` as any)}</h4>
<label>
<span>{t("factory.model")}</span>
<ModelSelect value={value.model} onChange={(model) => onChange({ model })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<label>
<span>{t("factory.agent")}</span>
<select value={value.agent} onChange={(e) => onChange({ agent: e.target.value })}>
<option value="codecraft">CodeCraft</option>
<option value="claude-code">Claude Code</option>
</select>
</label>
<label>
<span>{t("factory.mode")}</span>
<select value={value.permission_mode} onChange={(e) => onChange({ permission_mode: e.target.value })}>
{MODES.map((m) => (
<option key={m} value={m}>
{t(`permissionMode.${m}` as any)}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.timeout")}</span>
<input type="number" min={1} value={value.timeout_minutes} onChange={(e) => onChange({ timeout_minutes: Number(e.target.value) || 60 })} />
</label>
<label>
<span>{t("factory.extraPrompt")}</span>
<textarea rows={3} value={value.extra_prompt} onChange={(e) => onChange({ extra_prompt: e.target.value })} />
</label>
</div>
);
}
const URL_PLACEHOLDER: Record<string, string> = { gitea: "https://git.example.org", github: "https://github.com", gitlab: "https://gitlab.com" };
function FactorySettings() {
const t = useT();
const [st, setSt] = useState<Settings>();
const [busy, setBusy] = useState<string>();
const [orgs, setOrgs] = useState<Record<string, string[]>>({});
useEffect(() => {
get<Settings>("/account/factory")
.then(setSt)
.catch((e) => toast(e.message));
}, []);
if (!st) return <div className="empty"><Spinner /></div>;
const { connections: conns, repos } = st;
const setConns = (connections: Connection[]) => setSt({ ...st, connections });
const setRepos = (r: RepoCfg[]) => setSt({ ...st, repos: r });
const upConn = (i: number, patch: Partial<Connection>) => setConns(conns.map((c, k) => (k === i ? { ...c, ...patch } : c)));
const up = (i: number, patch: Partial<RepoCfg>) => setRepos(repos.map((r, k) => (k === i ? { ...r, ...patch } : r)));
const upPhase = (i: number, p: Phase, patch: Partial<PhaseCfg>) => up(i, { [p]: { ...repos[i][p], ...patch } } as any);
const upDefault = (p: Phase, patch: Partial<PhaseCfg>) => setSt({ ...st, defaults: { ...st.defaults, [p]: { ...st.defaults[p], ...patch } } });
const save = async () => {
const s = await put<Settings>("/account/factory", st);
setSt(s);
return s;
};
const act = async (key: string, f: () => Promise<void>) => {
setBusy(key);
try {
await f();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="notice">{t("factory.settingsHint")}</div>
<h3 className="factory-h">{t("factory.connections")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.connectionsHint")}</div>
{conns.map((c, i) => (
<div key={i} className="panel">
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>{c.name || KIND_NAME[c.kind]}</h3>
<span className="spacer" />
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`c${i}`, async () => {
const saved = await save();
const id = saved.connections[i]?.id;
const r = await post<{ login: string; orgs: string[] }>("/account/factory/connection/test", { id });
setOrgs({ ...orgs, [id]: r.orgs });
toast(t("factory.connOk", { login: r.login, n: r.orgs.length }), "success");
})
}
>
{busy === `c${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.testConn")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.connDeleteConfirm", { name: c.name || KIND_NAME[c.kind] }), danger: true })) setConns(conns.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connName")}</span>
<input value={c.name} onChange={(e) => upConn(i, { name: e.target.value })} />
</label>
<label>
<span>{t("factory.kind")}</span>
<select value={c.kind} onChange={(e) => upConn(i, { kind: e.target.value as Connection["kind"] })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.serviceUrl")}</span>
<input value={c.url} placeholder={URL_PLACEHOLDER[c.kind]} onChange={(e) => upConn(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.token")}</span>
<input type="password" autoComplete="new-password" value={c.token} placeholder={t("factory.tokenPlaceholder")} onChange={(e) => upConn(i, { token: e.target.value })} onFocus={(e) => c.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{c.token.startsWith("•") ? t("factory.tokenSet") : t(`factory.tokenHint.${c.kind}` as any)}</span>
</label>
<label>
<span>{t("factory.defaultOwner")}</span>
<input list={`orgs-${c.id}`} value={c.owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => upConn(i, { owner: e.target.value })} />
<datalist id={`orgs-${c.id}`}>
{(orgs[c.id] ?? []).map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
</div>
</div>
))}
<button onClick={() => setConns([...conns, blankConn()])}>
<Icon name="plus" /> {t("factory.addConn")}
</button>
<h3 className="factory-h">{t("factory.defaults")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.defaultsHint")}</div>
<div className="panel">
<label className="factory-form" style={{ margin: 0 }}>
<span style={{ fontSize: 12 }}>{t("factory.specModel")}</span>
<ModelSelect value={st.defaults.spec_model} onChange={(spec_model) => setSt({ ...st, defaults: { ...st.defaults, spec_model } })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<div className="factory-phases" style={{ marginTop: 10 }}>
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={st.defaults[p]} onChange={(patch) => upDefault(p, patch)} />
))}
</div>
</div>
<h3 className="factory-h">{t("factory.repos")}</h3>
{repos.map((r, i) => (
<div key={i} className="panel">
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<h3 style={{ margin: 0 }}>{r.url ? repoName(r.url) : t("factory.newRepo")}</h3>
<span className="chip">{KIND_NAME[conns.find((c) => c.id === r.connection)?.kind ?? r.kind] ?? r.kind}</span>
<span className="spacer" />
<label className="switch">
<input type="checkbox" checked={r.enabled} onChange={(e) => up(i, { enabled: e.target.checked })} />
<span>{t(r.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`t${i}`, async () => {
await save();
const res = await post<{ info: { login: string; repo: string; can_push: boolean }; labels_created: string[]; folder: string }>("/account/factory/test", { url: r.url });
toast(t("factory.testOk", { login: res.info.login, repo: res.info.repo, n: res.labels_created.length }), "success");
})
}
>
{busy === `t${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.test")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.deleteConfirm", { name: repoName(r.url) || "?" }), danger: true })) setRepos(repos.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connection")}</span>
<select
value={r.connection}
onChange={(e) => up(i, { connection: e.target.value, kind: conns.find((c) => c.id === e.target.value)?.kind ?? r.kind })}
>
<option value="">{t("factory.noConnection")}</option>
{conns
.filter((c) => c.id)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name || KIND_NAME[c.kind]}
</option>
))}
</select>
</label>
{!r.connection && (
<label>
<span>{t("factory.kind")}</span>
<select value={r.kind} onChange={(e) => up(i, { kind: e.target.value })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
)}
<label>
<span>{t("factory.url")}</span>
<input value={r.url} placeholder="https://host/owner/repo.git" onChange={(e) => up(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.repoToken")}</span>
<input type="password" autoComplete="new-password" value={r.token} placeholder={t("factory.repoTokenPlaceholder")} onChange={(e) => up(i, { token: e.target.value })} onFocus={(e) => r.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{r.token.startsWith("•") ? t("factory.tokenSet") : t("factory.repoTokenHint")}</span>
</label>
<label>
<span>{t("factory.label")}</span>
<input value={r.label} onChange={(e) => up(i, { label: e.target.value })} />
</label>
<label>
<span>{t("factory.parallel")}</span>
<input type="number" min={1} max={4} value={r.max_parallel} onChange={(e) => up(i, { max_parallel: Number(e.target.value) || 1 })} />
</label>
<label>
<span>{t("factory.allowed")}</span>
<input value={r.allowed_users.join(", ")} placeholder={t("factory.allowedPlaceholder")} onChange={(e) => up(i, { allowed_users: e.target.value.split(",").map((u) => u.trim()).filter(Boolean) })} />
</label>
</div>
<div className="factory-phases">
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={r[p]} onChange={(patch) => upPhase(i, p, patch)} />
))}
</div>
</div>
))}
<div className="row" style={{ gap: 8, marginTop: 12 }}>
<button onClick={() => setRepos([...repos, blankRepo(conns.find((c) => c.id)?.id ?? "", conns.find((c) => c.id)?.kind ?? "gitea")])}>
<Icon name="plus" /> {t("factory.addRepo")}
</button>
<button
className="primary"
disabled={busy !== undefined}
onClick={() =>
act("save", async () => {
await save();
toast(t("factory.saved"), "success");
})
}
>
{busy === "save" ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
</button>
</div>
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { navigate } from "../router";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
interface Draft {
spec: string;
proposal: { name?: string; slug?: string; description?: string } | null;
connections: { id: string; name: string; kind: string; owner: string }[];
}
export const isDraftChat = (cwd: string) => /\/_drafts\/[^/]+$/.test(cwd);
const slugify = (s: string) =>
s
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
/** Bar above the composer of a spec chat: file the spec as a new repository and start the workflow. */
export function DraftBar({ sessionId }: { sessionId: string }) {
const t = useT();
const [open, setOpen] = useState(false);
return (
<>
<div className="draft-bar">
<Icon name="scroll" />
<span>{t("draft.hint")}</span>
<span className="spacer" />
<button className="small primary" onClick={() => setOpen(true)}>
<Icon name="check" /> {t("draft.create")}
</button>
</div>
{open && <CreateProjectDialog sessionId={sessionId} onClose={() => setOpen(false)} />}
</>
);
}
function CreateProjectDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT();
const [draft, setDraft] = useState<Draft>();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const [connection, setConnection] = useState("");
const [owner, setOwner] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Draft>(`/factory/drafts/${sessionId}`)
.then((d) => {
setDraft(d);
setName(d.proposal?.name ?? "");
setSlug(d.proposal?.slug ? slugify(d.proposal.slug) : "");
setSlugTouched(!!d.proposal?.slug);
setDescription(d.proposal?.description ?? "");
setConnection(d.connections[0]?.id ?? "");
setOwner(d.connections[0]?.owner ?? "");
})
.catch((e) => {
toast(e.message);
onClose();
});
}, [sessionId]);
if (!draft)
return (
<div className="modal-backdrop">
<div className="modal">
<Spinner />
</div>
</div>
);
const conn = draft.connections.find((c) => c.id === connection);
const ready = draft.spec.trim() && name.trim() && slug.length >= 2 && connection;
const create = async () => {
setBusy(true);
try {
const r = await post<{ repo: string; issue: number }>(`/factory/drafts/${sessionId}/create`, { connection, name, slug, description, owner });
await post("/factory/poll", {}).catch(() => undefined);
toast(t("draft.created", { repo: r.repo, n: r.issue }), "success");
onClose();
navigate("/factory");
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={busy ? undefined : onClose}>
<div className="modal draft-modal" role="dialog" aria-label={t("draft.create")} onClick={(e) => e.stopPropagation()}>
<h3>{t("draft.create")}</h3>
{draft.connections.length === 0 ? (
<div className="notice err">
{t("draft.noConnection")}{" "}
<button className="link-btn" onClick={() => navigate("/factory")}>
{t("factory.tab.settings")}
</button>
</div>
) : (
<>
{!draft.spec.trim() && <div className="notice err">{t("draft.noSpec")}</div>}
<div className="factory-form">
<label>
<span>{t("draft.name")}</span>
<input
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
/>
</label>
<label>
<span>{t("draft.slug")}</span>
<input
value={slug}
className="mono"
onChange={(e) => {
setSlug(slugify(e.target.value));
setSlugTouched(true);
}}
/>
</label>
<label style={{ gridColumn: "1 / -1" }}>
<span>{t("draft.description")}</span>
<input value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label>
<span>{t("draft.connection")}</span>
<select
value={connection}
onChange={(e) => {
setConnection(e.target.value);
setOwner(draft.connections.find((c) => c.id === e.target.value)?.owner ?? "");
}}
>
{draft.connections.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.kind} ({c.kind})
</option>
))}
</select>
</label>
<label>
<span>{t("draft.owner")}</span>
<input value={owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => setOwner(e.target.value)} />
</label>
</div>
<div className="hint" style={{ margin: "6px 0" }}>
{t("draft.private", { conn: conn?.name || "" })}
</div>
<details>
<summary>{t("draft.specPreview")}</summary>
<pre className="draft-spec">{draft.spec || "–"}</pre>
</details>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
<button onClick={onClose} disabled={busy}>
{t("common.cancel")}
</button>
<button className="primary" onClick={create} disabled={!ready || busy}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("draft.createGo")}
</button>
</div>
</div>
</div>
);
}
+4
View File
@@ -491,6 +491,10 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
<Icon name="clock" className="nav-icon" />
{t("nav.jobs")}
</button>
<button className={`ghost ${section === "factory" ? "active" : ""}`} onClick={() => navigate("/factory")}>
<Icon name="toolbox" className="nav-icon" />
{t("nav.factory")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" />
{t("nav.stats")}
+106
View File
@@ -1168,4 +1168,110 @@ export const de: Messages = {
"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",
"nav.factory": "Factory",
"factory.title": "Software-Factory",
"factory.tab.overview": "Übersicht",
"factory.tab.settings": "Einstellungen",
"factory.empty": "Noch kein Repository verbunden. Füge in den Einstellungen eines hinzu: Issues mit dem Factory-Label werden dann von Agenten geprüft, gebaut und abgenommen.",
"factory.addRepo": "Repository hinzufügen",
"factory.newRepo": "Neues Repository",
"factory.noArea": "Der Factory-Bereich ist nicht eingebunden (FACTORY_DIR → /factory). Repositories können nicht geklont werden.",
"factory.kpi.running": "Läuft gerade",
"factory.kpi.issues": "{n} Issues",
"factory.kpi.done": "Fertig",
"factory.kpi.failed": "{n} fehlgeschlagen oder abgelehnt",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Kosten",
"factory.kpi.runs": "{n} Läufe",
"factory.kpi.time": "Ø Laufzeit",
"factory.kpi.perRun": "pro beendetem Lauf",
"factory.in": "ein",
"factory.out": "aus",
"factory.allRepos": "Alle Projekte",
"factory.repoLine": "{n} Issues · {run} laufen",
"factory.noToken": "kein Token",
"factory.pollNow": "Jetzt prüfen",
"factory.col.review": "Prüfung",
"factory.col.question": "Rückfragen",
"factory.col.approved": "Freigegeben",
"factory.col.build": "Bau",
"factory.col.qa": "Abnahme",
"factory.col.done": "Fertig",
"factory.col.failed": "Fehlgeschlagen / abgelehnt",
"factory.phase": "Phase",
"factory.phase.review": "Prüfung",
"factory.phase.build": "Bau",
"factory.phase.qa": "Abnahme",
"factory.latestRuns": "Letzte Läufe",
"factory.noRuns": "Noch keine Läufe.",
"factory.issue": "Issue",
"factory.model": "Modell",
"factory.status": "Status",
"factory.started": "Gestartet",
"factory.duration": "Dauer",
"factory.tokens": "Tokens",
"factory.cost": "Kosten",
"factory.pr": "Pull Request",
"factory.round": "Runde {n}",
"factory.openChat": "Chat öffnen",
"factory.runStatus.running": "läuft",
"factory.runStatus.done": "fertig",
"factory.runStatus.timeout": "Zeitlimit",
"factory.runStatus.error": "Fehler",
"factory.runStatus.no-result": "kein Ergebnis",
"factory.runStatus.interrupted": "unterbrochen",
"factory.settingsHint": "Specs kommen als Gitea-Issues mit dem Factory-Label (\"Spec: <Pfad>\" im Text). Jede Phase läuft als eigener Chat in der Sandbox mit eigenem Modell. Repositories werden in den separaten Factory-Bereich geklont, nicht in den Workspace; die Agenten sehen den API-Token nie.",
"factory.url": "Repository-URL",
"factory.token": "Gitea-API-Token",
"factory.tokenPlaceholder": "Token mit Rechten für Issues und Pull Requests",
"factory.tokenSet": "Ein Token ist gespeichert. Gib einen neuen ein, um ihn zu ersetzen.",
"factory.label": "Issue-Label",
"factory.parallel": "Parallele Läufe",
"factory.allowed": "Erlaubte Issue-Autoren",
"factory.allowedPlaceholder": "leer = nur der Token-Besitzer",
"factory.agent": "Agent",
"factory.mode": "Berechtigungsmodus",
"factory.timeout": "Zeitlimit (Minuten)",
"factory.extraPrompt": "Zusätzliche Anweisungen",
"factory.test": "Verbindung testen",
"factory.testOk": "Verbunden als {login} mit {repo}; {n} Labels angelegt, Repository geklont.",
"factory.deleteConfirm": "{name} aus der Factory entfernen? Issues und Chats bleiben.",
"factory.newProject": "Neues Projekt",
"factory.connections": "Verbindungen",
"factory.connectionsHint": "Deine Konten bei Gitea, GitHub oder GitLab. Jeder Nutzer richtet seine eigenen ein; Repositories nutzen den Token ihrer Verbindung, sofern sie keinen eigenen haben.",
"factory.connName": "Name",
"factory.kind": "Dienst",
"factory.serviceUrl": "Adresse des Dienstes",
"factory.defaultOwner": "Standard-Besitzer für neue Repositories",
"factory.addConn": "Verbindung hinzufügen",
"factory.testConn": "Testen",
"factory.connOk": "Angemeldet als {login}; {n} Organisationen oder Gruppen gefunden.",
"factory.connDeleteConfirm": "Verbindung {name} entfernen? Repositories funktionieren danach nur mit eigenem Token.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – zum Anlegen von Repositories zusätzlich write:user (oder write:organization).",
"factory.tokenHint.github": "Classic-Token mit Scope \"repo\" oder fein abgestufter Token mit Contents, Issues und Pull requests (Lesen/Schreiben).",
"factory.tokenHint.gitlab": "Persönlicher oder Projekt-Zugriffstoken mit Scope \"api\".",
"factory.defaults": "Standard für neue Projekte",
"factory.defaultsHint": "Modelle und Grenzen, mit denen ein Projekt startet; du kannst sie danach pro Projekt ändern.",
"factory.specModel": "Modell des Spec-Chats",
"factory.repos": "Repositories",
"factory.connection": "Verbindung",
"factory.noConnection": "– keine (eigener Token) –",
"factory.repoToken": "Eigener Token (optional)",
"factory.repoTokenPlaceholder": "leer = Token der Verbindung",
"factory.repoTokenHint": "Nur nötig, wenn dieses Repository einen anderen Token nutzen soll.",
"draft.hint": "Spec-Chat: Wenn die Spec fertig ist, lege sie als neues Repository ab.",
"draft.create": "Projekt anlegen",
"draft.createGo": "Repository anlegen und starten",
"draft.name": "Projektname",
"draft.slug": "Repository-Name",
"draft.description": "Kurzbeschreibung",
"draft.connection": "Verbindung",
"draft.owner": "Besitzer",
"draft.ownerPlaceholder": "leer = dein eigenes Konto",
"draft.private": "Das Repository wird privat angelegt. Die Spec wird als docs/specs/<name>.md committet und ein Issue startet den Workflow.",
"draft.specPreview": "Spec anzeigen",
"draft.noSpec": "In diesem Chat gibt es noch keine spec.md – lass sie zuerst vom Modell schreiben.",
"draft.noConnection": "Richte zuerst eine Verbindung (Gitea, GitHub oder GitLab) ein:",
"draft.created": "{repo} angelegt, Issue #{n} eröffnet – die Prüfung startet gleich.",
"factory.saved": "Gespeichert",
};
+106
View File
@@ -1167,6 +1167,112 @@ export const en = {
"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",
"nav.factory": "Factory",
"factory.title": "Software factory",
"factory.tab.overview": "Overview",
"factory.tab.settings": "Settings",
"factory.empty": "No repository connected yet. Add one in the settings: issues with the factory label are then reviewed, built and checked by agents.",
"factory.addRepo": "Add repository",
"factory.newRepo": "New repository",
"factory.noArea": "The factory area is not mounted (FACTORY_DIR → /factory). Repositories cannot be cloned.",
"factory.kpi.running": "Running now",
"factory.kpi.issues": "{n} issues",
"factory.kpi.done": "Done",
"factory.kpi.failed": "{n} failed or rejected",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Cost",
"factory.kpi.runs": "{n} runs",
"factory.kpi.time": "Avg. run time",
"factory.kpi.perRun": "per finished run",
"factory.in": "in",
"factory.out": "out",
"factory.allRepos": "All projects",
"factory.repoLine": "{n} issues · {run} running",
"factory.noToken": "no token",
"factory.pollNow": "Check now",
"factory.col.review": "Review",
"factory.col.question": "Questions",
"factory.col.approved": "Approved",
"factory.col.build": "Build",
"factory.col.qa": "Acceptance",
"factory.col.done": "Done",
"factory.col.failed": "Failed / rejected",
"factory.phase": "Phase",
"factory.phase.review": "Review",
"factory.phase.build": "Build",
"factory.phase.qa": "Acceptance",
"factory.latestRuns": "Latest runs",
"factory.noRuns": "No runs yet.",
"factory.issue": "Issue",
"factory.model": "Model",
"factory.status": "Status",
"factory.started": "Started",
"factory.duration": "Duration",
"factory.tokens": "Tokens",
"factory.cost": "Cost",
"factory.pr": "Pull request",
"factory.round": "round {n}",
"factory.openChat": "Open chat",
"factory.runStatus.running": "running",
"factory.runStatus.done": "done",
"factory.runStatus.timeout": "timeout",
"factory.runStatus.error": "error",
"factory.runStatus.no-result": "no result",
"factory.runStatus.interrupted": "interrupted",
"factory.settingsHint": "Specs arrive as Gitea issues with the factory label (\"Spec: <path>\" in the text). Each phase runs as its own chat in the sandbox with its own model. Repositories are cloned into the separate factory area, not into the workspace; agents never see the API token.",
"factory.url": "Repository URL",
"factory.token": "Gitea API token",
"factory.tokenPlaceholder": "Token with rights for issues and pull requests",
"factory.tokenSet": "A token is stored. Type a new one to replace it.",
"factory.label": "Issue label",
"factory.parallel": "Parallel runs",
"factory.allowed": "Allowed issue authors",
"factory.allowedPlaceholder": "empty = only the token's owner",
"factory.agent": "Agent",
"factory.mode": "Permission mode",
"factory.timeout": "Timeout (minutes)",
"factory.extraPrompt": "Extra instructions",
"factory.test": "Test connection",
"factory.testOk": "Connected as {login} to {repo}; {n} labels created, repository cloned.",
"factory.deleteConfirm": "Remove {name} from the factory? Issues and chats stay.",
"factory.newProject": "New project",
"factory.connections": "Connections",
"factory.connectionsHint": "Your accounts at Gitea, GitHub or GitLab. Each user sets up their own; repositories use the token of their connection unless they have a token of their own.",
"factory.connName": "Name",
"factory.kind": "Service",
"factory.serviceUrl": "Address of the service",
"factory.defaultOwner": "Default owner for new repositories",
"factory.addConn": "Add connection",
"factory.testConn": "Test",
"factory.connOk": "Signed in as {login}; {n} organisations or groups found.",
"factory.connDeleteConfirm": "Remove the connection {name}? Repositories keep working only with a token of their own.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – plus write:user (or write:organization) to create repositories.",
"factory.tokenHint.github": "Classic token with scope \"repo\", or a fine-grained token with Contents, Issues and Pull requests (read/write).",
"factory.tokenHint.gitlab": "Personal or project access token with scope \"api\".",
"factory.defaults": "Defaults for new projects",
"factory.defaultsHint": "Models and limits a project starts with; you can change them per project afterwards.",
"factory.specModel": "Model of the spec chat",
"factory.repos": "Repositories",
"factory.connection": "Connection",
"factory.noConnection": "– none (own token) –",
"factory.repoToken": "Own token (optional)",
"factory.repoTokenPlaceholder": "empty = token of the connection",
"factory.repoTokenHint": "Only needed when this repository should use a different token.",
"draft.hint": "Spec chat: when the spec is done, file it as a new repository.",
"draft.create": "Create project",
"draft.createGo": "Create repository and start",
"draft.name": "Project name",
"draft.slug": "Repository name",
"draft.description": "Short description",
"draft.connection": "Connection",
"draft.owner": "Owner",
"draft.ownerPlaceholder": "empty = your own account",
"draft.private": "The repository is created private. The spec is committed as docs/specs/<name>.md and an issue starts the workflow.",
"draft.specPreview": "Show spec",
"draft.noSpec": "There is no spec.md in this chat yet – let the model write it first.",
"draft.noConnection": "Set up a connection (Gitea, GitHub or GitLab) first:",
"draft.created": "{repo} created, issue #{n} opened – the review starts shortly.",
"factory.saved": "Saved",
};
export type Messages = Record<keyof typeof en, string>;
+150
View File
@@ -5020,3 +5020,153 @@ td.fold-indent {
.ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
/* --- software factory ------------------------------------------------------ */
.button-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
text-decoration: none;
color: var(--fg1);
}
.table-wrap {
overflow-x: auto;
}
.factory-repos {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
align-items: stretch;
}
.factory-repo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg1);
border: 1px solid var(--bg2);
border-radius: var(--radius);
padding: 8px 12px;
min-width: 150px;
}
.factory-repo.active {
border-color: var(--accent);
}
.factory-repo.off {
opacity: 0.6;
}
.factory-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
}
.factory-col {
background: var(--bg1);
border-radius: var(--radius);
padding: 8px;
min-height: 120px;
display: flex;
flex-direction: column;
gap: 6px;
}
.factory-col h4 {
margin: 0 0 2px;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.factory-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg0);
border: 1px solid var(--bg2);
border-radius: 8px;
padding: 8px 10px;
}
.factory-card.active {
border-color: var(--accent);
}
.factory-card.running {
border-left: 3px solid var(--accent);
}
.factory-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 5px;
animation: factory-pulse 1.4s ease-in-out infinite;
}
@keyframes factory-pulse {
50% {
opacity: 0.25;
}
}
.factory-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
margin: 12px 0;
}
.factory-form label,
.factory-phase label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--fg3);
}
.factory-phases {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.factory-phase {
background: var(--bg1);
border-radius: var(--radius);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.factory-phase h4 {
margin: 0;
}
.factory-h {
margin: 22px 0 6px;
}
.draft-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg1);
border-top: 1px solid var(--bg2);
font-size: 13px;
color: var(--fg2);
}
.draft-modal {
width: min(640px, calc(100vw - 32px));
}
.draft-spec {
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
background: var(--bg1);
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
+8
View File
@@ -3,6 +3,14 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00041": {
en: ["New: software factory – write a spec with a model, create the repository (Gitea, GitHub or GitLab) and let agents review it, a model per phase", "Factory overview with board, tokens, run time and links into the runs"],
de: ["Neu: Software-Factory – Spec mit einem Modell schreiben, Repository anlegen (Gitea, GitHub oder GitLab) und von Agenten prüfen lassen, ein Modell pro Phase", "Factory-Übersicht mit Board, Tokens, Laufzeit und Links in die Läufe"],
},
"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)"],