Add Web Push, guided start screen, SVG icons, chunked uploads, rename

- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 21:03:26 +01:00
1 parent 013695722a
commit a0d0e707f2
48 files changed
+2699 -203

No files matched your search

+3
View File
@@ -43,6 +43,9 @@ mcp_direct_limit = 15
# Currency label for the cost statistics (prices below are in this currency). # Currency label for the cost statistics (prices below are in this currency).
currency = "USD" currency = "USD"
# Largest file upload in MB. Uploads are sent in 8 MB chunks, so the reverse
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
max_upload_mb = 500
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+342
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "aes-gcm"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ctutils",
"ghash",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@@ -167,6 +202,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -324,6 +365,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"inout",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -343,6 +395,7 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
name = "codecraft" name = "codecraft"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"anyhow", "anyhow",
"argon2", "argon2",
"async-trait", "async-trait",
@@ -354,10 +407,12 @@ dependencies = [
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"globset", "globset",
"hkdf",
"html2text", "html2text",
"ignore", "ignore",
"image", "image",
"libc", "libc",
"p256",
"rand 0.10.3", "rand 0.10.3",
"regex", "regex",
"reqwest", "reqwest",
@@ -434,6 +489,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -486,6 +547,22 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-bigint"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
dependencies = [
"cpubits",
"ctutils",
"getrandom 0.4.3",
"hybrid-array",
"num-traits",
"rand_core 0.10.1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -502,7 +579,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"getrandom 0.4.3",
"hybrid-array", "hybrid-array",
"rand_core 0.10.1",
]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher",
] ]
[[package]] [[package]]
@@ -512,6 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
dependencies = [ dependencies = [
"cmov", "cmov",
"subtle",
] ]
[[package]] [[package]]
@@ -526,6 +615,17 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
[[package]]
name = "der"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -565,6 +665,43 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecdsa"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
dependencies = [
"der",
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
"zeroize",
]
[[package]]
name = "elliptic-curve"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
dependencies = [
"base16ct",
"crypto-bigint",
"crypto-common 0.2.2",
"digest 0.11.3",
"ff",
"group",
"hkdf",
"hybrid-array",
"pem-rfc7468",
"pkcs8",
"rand_core 0.10.1",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.44" version = "0.8.44"
@@ -633,6 +770,16 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "ff"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
dependencies = [
"rand_core 0.10.1",
"subtle",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.15" version = "0.1.15"
@@ -818,6 +965,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval",
]
[[package]] [[package]]
name = "globset" name = "globset"
version = "0.4.21" version = "0.4.21"
@@ -831,6 +987,17 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "group"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
dependencies = [
"ff",
"rand_core 0.10.1",
"subtle",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -849,6 +1016,24 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]] [[package]]
name = "html2text" name = "html2text"
version = "0.15.5" version = "0.15.5"
@@ -929,7 +1114,9 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [ dependencies = [
"subtle",
"typenum", "typenum",
"zeroize",
] ]
[[package]] [[package]]
@@ -1171,6 +1358,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.2" version = "2.12.2"
@@ -1479,6 +1675,19 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]]
name = "p256"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
dependencies = [
"ecdsa",
"elliptic-curve",
"primefield",
"primeorder",
"sha2",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1512,6 +1721,15 @@ dependencies = [
"phc", "phc",
] ]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1573,6 +1791,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -1592,6 +1820,17 @@ dependencies = [
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
] ]
[[package]]
name = "polyval"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
dependencies = [
"cpubits",
"cpufeatures 0.3.1",
"universal-hash",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.6" version = "0.1.6"
@@ -1616,6 +1855,33 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primefield"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
dependencies = [
"crypto-bigint",
"crypto-common 0.2.2",
"ff",
"rand_core 0.10.1",
"subtle",
"zeroize",
]
[[package]]
name = "primeorder"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
dependencies = [
"elliptic-curve",
"once_cell",
"primefield",
"serdect",
"wnaf",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -1874,6 +2140,16 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfc6979"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
dependencies = [
"crypto-bigint",
"hmac",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2038,6 +2314,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
dependencies = [
"base16ct",
"ctutils",
"der",
"hybrid-array",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -2142,6 +2432,16 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serdect"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct",
"serde",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.10.7"
@@ -2195,6 +2495,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
dependencies = [
"digest 0.11.3",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -2251,6 +2561,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spki"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "sqlite-wasm-rs" name = "sqlite-wasm-rs"
version = "0.6.2" version = "0.6.2"
@@ -2697,6 +3017,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "universal-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
dependencies = [
"crypto-common 0.2.2",
"ctutils",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -3073,6 +3403,18 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wnaf"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
dependencies = [
"ff",
"group",
"hybrid-array",
"primefield",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+3
View File
@@ -38,3 +38,6 @@ tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "v7"] } uuid = { version = "1", features = ["v4", "v7"] }
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
hkdf = "0.13"
aes-gcm = "0.11"
+1 -1
View File
@@ -5,6 +5,6 @@ clean_room: false
--- ---
Load the skill "recreate" with the skill tool and carry out phases 1–4 for the software in Load the skill "recreate" with the skill tool and carry out phases 1–4 for the software in
original/ (inventory and unpacking, analysis, documentation and specification, proposal). original/ (inventory and unpacking, analysis, documentation and specification, proposal).
Track the phases with todo_write. Stop after the proposal and ask the user how to proceed. Report the workflow phase with craft_phase (study while taking inventory and analysing, specify while documenting and writing the proposal) and track the steps inside a phase with todo_write. Stop after the proposal and ask the user how to proceed.
Additional notes from the user: $ARGUMENTS Additional notes from the user: $ARGUMENTS
+1 -1
View File
@@ -88,5 +88,5 @@ ask_user how to proceed. Do not start building before the user agrees.
code are new. Facts needed for compatibility (file formats, constants, rules) are fine. code are new. Facts needed for compatibility (file formats, constants, rules) are fine.
- Original assets (graphics, sounds, levels) stay the user's files: the re-creation may load - Original assets (graphics, sounds, levels) stay the user's files: the re-creation may load
them from `original/` at runtime but does not copy them into the repository. them from `original/` at runtime but does not copy them into the repository.
- Use todo_write for the phases; delegate broad searches through decompiled output to - Report phases with craft_phase and use todo_write for the steps; delegate broad searches through decompiled output to
explore subagents to keep your own context small. explore subagents to keep your own context small.
+83 -2
View File
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
.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))
.route("/sessions/{id}/title", post(suggest_title))
.route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}/export", get(export_session))
.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}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
@@ -120,6 +121,9 @@ pub fn router(app: AppState) -> Router {
.route("/images/config", get(images_config)) .route("/images/config", get(images_config))
.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("/push/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test))
.route("/ws", get(ws)) .route("/ws", get(ws))
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user)) .layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
// Prompts may carry images (base64); the default limit is 2 MB. // Prompts may carry images (base64); the default limit is 2 MB.
@@ -163,12 +167,13 @@ 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, "currency": cfg.server.currency}))); return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
} }
Ok(Json(json!({ Ok(Json(json!({
"config": config::redacted_json(&cfg), "config": config::redacted_json(&cfg),
"workspace": app.workspace(), "workspace": app.workspace(),
"default_model": default_model, "default_model": default_model,
"small_model": cfg.server.small_model,
"currency": cfg.server.currency, "currency": cfg.server.currency,
}))) })))
} }
@@ -320,6 +325,7 @@ struct CreateSession {
model: Option<String>, model: Option<String>,
agent: Option<String>, agent: Option<String>,
permission_mode: Option<String>, permission_mode: Option<String>,
clean_room: Option<bool>,
} }
/// Resolve a directory and check it lies inside one of the user's roots. /// Resolve a directory and check it lies inside one of the user's roots.
@@ -364,7 +370,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
updated_at: now, updated_at: now,
todos: Value::Null, todos: Value::Null,
last_usage: None, last_usage: None,
clean_room: None, clean_room: body.clean_room,
phase: None,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -389,6 +396,19 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
Ok(Json(json!(hits))) Ok(Json(json!(hits)))
} }
#[derive(Deserialize, Default)]
struct TitleBody {
model: Option<String>,
}
/// Generate a title suggestion with a chosen model (not saved).
async fn suggest_title(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<TitleBody>>) -> ApiResult {
writable(&app, &user, &id)?;
let model = body.and_then(|b| b.0.model);
let title = crate::runner::suggest_title(app.clone(), &id, model).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"title": title})))
}
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
struct ForkBody { struct ForkBody {
/// Last message to keep (inclusive); all messages when missing. /// Last message to keep (inclusive); all messages when missing.
@@ -414,6 +434,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
created_at: now, created_at: now,
updated_at: now, updated_at: now,
last_usage: None, last_usage: None,
phase: None,
..src.clone() ..src.clone()
}; };
let fork = app.save_session(fork)?; let fork = app.save_session(fork)?;
@@ -988,6 +1009,50 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
// --- websocket -------------------------------------------------------------- // --- websocket --------------------------------------------------------------
// --- web push -------------------------------------------------------------------
async fn push_key(State(app): State<AppState>) -> ApiResult {
Ok(Json(json!({"key": app.push.public_key()})))
}
#[derive(Deserialize)]
struct PushKeys {
p256dh: String,
auth: String,
}
#[derive(Deserialize)]
struct PushSubscription {
endpoint: String,
keys: PushKeys,
}
async fn push_subscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(sub): Json<PushSubscription>) -> ApiResult {
if !sub.endpoint.starts_with("https://") {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid push endpoint".into()));
}
app.store.push_add(&user.id, &sub.endpoint, &sub.keys.p256dh, &sub.keys.auth)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct PushEndpoint {
endpoint: String,
}
async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<PushEndpoint>) -> ApiResult {
// Only the owner's own subscription.
if app.store.push_subscriptions(&user.id)?.iter().any(|s| s.endpoint == b.endpoint) {
app.store.push_remove(&b.endpoint)?;
}
Ok(Json(json!({"ok": true})))
}
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
Ok(Json(json!({"sent": sent})))
}
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response { async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket)) upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
} }
@@ -1002,6 +1067,7 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25)); let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
// Re-check the login regularly so disabled users lose their live connection. // Re-check the login regularly so disabled users lose their live connection.
let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60)); let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60));
let mut visible = false;
loop { loop {
tokio::select! { tokio::select! {
ev = rx.recv() => match ev { ev = rx.recv() => match ev {
@@ -1021,6 +1087,18 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
}, },
msg = socket.recv() => match msg { msg = socket.recv() => match msg {
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break, Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(WsMessage::Text(t))) => {
// The client reports whether its window is visible (push only when none is).
if let Ok(v) = serde_json::from_str::<Value>(&t) {
if v["type"] == "visibility" {
let now = v["visible"].as_bool().unwrap_or(false);
if now != visible {
app.push.set_visible(&user.id, if now { 1 } else { -1 });
visible = now;
}
}
}
}
_ => {} _ => {}
}, },
_ = ping.tick() => { _ = ping.tick() => {
@@ -1035,6 +1113,9 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
} }
} }
} }
if visible {
app.push.set_visible(&user.id, -1);
}
} }
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> { async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
+3
View File
@@ -71,6 +71,8 @@ pub struct App {
pub shells: crate::shells::Shells, pub shells: crate::shells::Shells,
/// Language servers (diagnostics after edits). /// Language servers (diagnostics after edits).
pub lsp: crate::lsp::Lsp, pub lsp: crate::lsp::Lsp,
/// Web Push (VAPID key, window visibility per user).
pub push: crate::push::Push,
} }
/// What a user may do with a session. /// What a user may do with a session.
@@ -108,6 +110,7 @@ impl App {
tool_install: Mutex::default(), tool_install: Mutex::default(),
shells: Mutex::default(), shells: Mutex::default(),
lsp: Default::default(), lsp: Default::default(),
push: crate::push::Push::new(&data),
}); });
crate::auth::bootstrap(&app)?; crate::auth::bootstrap(&app)?;
app.clone().start_mcp(); app.clone().start_mcp();
+3
View File
@@ -246,6 +246,8 @@ pub struct ServerConfig {
pub budget_warning: f64, pub budget_warning: f64,
/// "warn" (default) or "block": refuse new model requests once a budget is used up. /// "warn" (default) or "block": refuse new model requests once a budget is used up.
pub budget_action: String, pub budget_action: String,
/// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply).
pub max_upload_mb: u64,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -263,6 +265,7 @@ impl Default for ServerConfig {
monthly_budget: 0.0, monthly_budget: 0.0,
budget_warning: 0.8, budget_warning: 0.8,
budget_action: "warn".into(), budget_action: "warn".into(),
max_upload_mb: 500,
} }
} }
} }
+21 -2
View File
@@ -373,16 +373,35 @@ pub async fn remove(Extension(user): Extension<User>, State(app): State<AppState
pub struct UploadQuery { pub struct UploadQuery {
/// Target file path (absolute). /// Target file path (absolute).
path: String, path: String,
/// Chunked upload: byte offset of this chunk (0 = first chunk, creates the file).
offset: Option<u64>,
} }
/// Upload one file as the raw request body. /// Upload a file as the raw request body – whole, or in chunks with `offset`
/// (large files: each chunk stays below the reverse proxy's body limit).
pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult { pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
let p = checked_path(&app, &user, &q.path, false)?; let p = checked_path(&app, &user, &q.path, false)?;
if p.is_dir() { if p.is_dir() {
return Err(bad("is a directory")); return Err(bad("is a directory"));
} }
let max = app.config().server.max_upload_mb.saturating_mul(1024 * 1024);
let offset = q.offset.unwrap_or(0);
if offset + body.len() as u64 > max {
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("file is larger than {} MB", max / 1024 / 1024)));
}
if offset == 0 {
tokio::fs::write(&p, &body).await?; tokio::fs::write(&p, &body).await?;
Ok(Json(json!({"ok": true, "path": p, "size": body.len()}))) } else {
use tokio::io::AsyncWriteExt;
let current = tokio::fs::metadata(&p).await.map(|m| m.len()).unwrap_or(0);
if current != offset {
return Err(ApiError(StatusCode::CONFLICT, format!("chunk offset {offset} does not match the uploaded size {current}")));
}
let mut f = tokio::fs::OpenOptions::new().append(true).open(&p).await?;
f.write_all(&body).await?;
f.flush().await?;
}
Ok(Json(json!({"ok": true, "path": p, "size": offset + body.len() as u64})))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+2
View File
@@ -16,6 +16,7 @@ mod mentions;
mod mcp; mod mcp;
mod permission; mod permission;
mod prompts; mod prompts;
mod push;
mod provider; mod provider;
mod reverse; mod reverse;
mod runner; mod runner;
@@ -47,6 +48,7 @@ async fn main() -> Result<()> {
let bind = cfg.server.bind.clone(); let bind = cfg.server.bind.clone();
let app = app::App::new(path, cfg).await?; let app = app::App::new(path, cfg).await?;
lsp::spawn_reaper(app.clone()); lsp::spawn_reaper(app.clone());
push::spawn_listener(app.clone());
let router = api::router(app); let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}"); tracing::info!("listening on http://{bind}");
+1 -1
View File
@@ -50,7 +50,7 @@ const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify an
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits. const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#; Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase). const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase.
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly. 1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code. 2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
+303
View File
@@ -0,0 +1,303 @@
//! Web Push notifications (VAPID, RFC 8030/8291/8292) without external services.
//!
//! The server sends a push to a user's subscribed devices when an agent asks
//! for permission, asks a question, or finishes a longer run – but only while
//! none of the user's app windows is visible.
use crate::app::App;
use crate::types::Event;
use aes_gcm::aead::{Aead, KeyInit};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use hkdf::Hkdf;
use p256::ecdsa::signature::Signer;
use p256::elliptic_curve::sec1::ToSec1Point;
use p256::{PublicKey, SecretKey};
use serde_json::{Value, json};
use sha2::Sha256;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Runs shorter than this don't trigger a "finished" notification.
const MIN_RUN_FOR_DONE: Duration = Duration::from_secs(20);
pub struct Push {
key_path: PathBuf,
key: OnceLock<SecretKey>,
/// user id -> number of visible app windows (reported over the WebSocket)
visible: Mutex<HashMap<String, usize>>,
/// session id -> start of the current run
runs: Mutex<HashMap<String, Instant>>,
}
fn random_bytes<const N: usize>() -> [u8; N] {
rand::random()
}
impl Push {
pub fn new(data_dir: &std::path::Path) -> Self {
Self { key_path: data_dir.join("vapid.key"), key: OnceLock::new(), visible: Mutex::default(), runs: Mutex::default() }
}
/// The VAPID key pair, created on first use and kept on the data volume.
fn key(&self) -> &SecretKey {
self.key.get_or_init(|| {
if let Ok(bytes) = std::fs::read(&self.key_path) {
if let Ok(k) = SecretKey::from_slice(&bytes) {
return k;
}
}
let k = loop {
if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) {
break k;
}
};
let _ = std::fs::write(&self.key_path, k.to_bytes());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600));
}
k
})
}
/// Public key for PushManager.subscribe (base64url, uncompressed point).
pub fn public_key(&self) -> String {
B64.encode(self.key().public_key().as_affine().to_uncompressed_point())
}
pub fn set_visible(&self, user_id: &str, delta: isize) {
let mut v = self.visible.lock().unwrap();
let n = v.entry(user_id.to_string()).or_default();
*n = (*n as isize + delta).max(0) as usize;
}
fn is_visible(&self, user_id: &str) -> bool {
self.visible.lock().unwrap().get(user_id).is_some_and(|n| *n > 0)
}
}
/// RFC 8291 "aes128gcm" encryption of one record.
fn encrypt(ua_public: &[u8], auth: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let as_secret = loop {
if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) {
break k;
}
};
encrypt_with(ua_public, auth, plaintext, &as_secret, random_bytes::<16>())
}
fn encrypt_with(ua_public: &[u8], auth: &[u8], plaintext: &[u8], as_secret: &SecretKey, salt: [u8; 16]) -> Result<Vec<u8>> {
let ua = PublicKey::from_sec1_bytes(ua_public).map_err(|_| anyhow!("invalid p256dh key"))?;
let as_public = as_secret.public_key().as_affine().to_uncompressed_point();
let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua.as_affine());
// IKM from the ECDH secret and the subscription's auth secret.
let mut key_info = b"WebPush: info\0".to_vec();
key_info.extend_from_slice(ua_public);
key_info.extend_from_slice(&as_public);
let mut ikm = [0u8; 32];
Hkdf::<Sha256>::new(Some(auth), shared.raw_secret_bytes()).expand(&key_info, &mut ikm).map_err(|_| anyhow!("hkdf"))?;
let prk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
let mut cek = [0u8; 16];
let mut nonce = [0u8; 12];
prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).map_err(|_| anyhow!("hkdf"))?;
prk.expand(b"Content-Encoding: nonce\0", &mut nonce).map_err(|_| anyhow!("hkdf"))?;
let mut record = plaintext.to_vec();
record.push(2); // padding delimiter: last record
let cipher = aes_gcm::Aes128Gcm::new_from_slice(&cek).map_err(|_| anyhow!("aes key"))?;
let ct = cipher.encrypt(&nonce.into(), record.as_slice()).map_err(|_| anyhow!("encryption failed"))?;
let mut out = Vec::with_capacity(16 + 4 + 1 + 65 + ct.len());
out.extend_from_slice(&salt);
out.extend_from_slice(&4096u32.to_be_bytes());
out.push(as_public.len() as u8);
out.extend_from_slice(&as_public);
out.extend_from_slice(&ct);
Ok(out)
}
/// VAPID authorization header (RFC 8292) for an endpoint.
fn vapid_header(key: &SecretKey, endpoint: &str, subject: &str) -> Result<String> {
let url = reqwest::Url::parse(endpoint)?;
let aud = url.origin().ascii_serialization();
let exp = chrono::Utc::now().timestamp() + 12 * 3600;
let header = B64.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
let claims = B64.encode(serde_json::to_vec(&json!({"aud": aud, "exp": exp, "sub": subject}))?);
let signing_input = format!("{header}.{claims}");
let sk = p256::ecdsa::SigningKey::from(key);
let sig: p256::ecdsa::Signature = sk.sign(signing_input.as_bytes());
let jwt = format!("{signing_input}.{}", B64.encode(sig.to_bytes()));
let k = B64.encode(key.public_key().as_affine().to_uncompressed_point());
Ok(format!("vapid t={jwt}, k={k}"))
}
/// Send a notification to all devices of a user.
pub async fn send_to_user(app: &App, user_id: &str, payload: &Value) -> usize {
let subs = app.store.push_subscriptions(user_id).unwrap_or_default();
let mut sent = 0;
for sub in subs {
match send_one(app, &sub.endpoint, &sub.p256dh, &sub.auth, payload).await {
Ok(()) => sent += 1,
Err(e) => {
let gone = e.to_string().contains("410") || e.to_string().contains("404");
tracing::debug!("push to {}: {e:#}", sub.endpoint);
if gone {
let _ = app.store.push_remove(&sub.endpoint);
}
}
}
}
sent
}
async fn send_one(app: &App, endpoint: &str, p256dh: &str, auth: &str, payload: &Value) -> Result<()> {
let ua = B64.decode(p256dh.trim_end_matches('=')).context("p256dh")?;
let auth = B64.decode(auth.trim_end_matches('=')).context("auth")?;
let body = encrypt(&ua, &auth, &serde_json::to_vec(payload)?)?;
let cfg = app.config();
let subject = if cfg.auth.public_url.starts_with("https://") { cfg.auth.public_url.clone() } else { "mailto:admin@localhost".to_string() };
let resp = crate::provider::http_client()
.post(endpoint)
.header("Authorization", vapid_header(app.push.key(), endpoint, &subject)?)
.header("Content-Encoding", "aes128gcm")
.header("Content-Type", "application/octet-stream")
.header("TTL", "86400")
.header("Urgency", "high")
.timeout(Duration::from_secs(20))
.body(body)
.send()
.await?;
if !resp.status().is_success() {
bail!("HTTP {}: {}", resp.status().as_u16(), resp.text().await.unwrap_or_default().chars().take(200).collect::<String>());
}
Ok(())
}
/// Owner and title of the top-level session.
fn session_info(app: &App, session_id: &str) -> Option<(String, String, String)> {
let root = app.root_session_id(session_id);
let s = app.store.get_session(&root).ok().flatten()?;
let title = if s.title.is_empty() { "CodeCraft".to_string() } else { s.title.clone() };
Some((s.owner_id?, title, root))
}
/// Background task: turn agent events into notifications.
pub fn spawn_listener(app: Arc<App>) {
tokio::spawn(async move {
let mut rx = app.subscribe();
loop {
let ev = match rx.recv().await {
Ok(ev) => ev,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
};
let (session_id, kind, body) = match &ev {
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")),
Event::QuestionRequest { session_id, questions, .. } => {
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
(session_id.clone(), "question", q)
}
Event::SessionStatus { session_id, busy: true } => {
app.push.runs.lock().unwrap().insert(session_id.clone(), Instant::now());
continue;
}
Event::SessionStatus { session_id, busy: false } => {
let started = app.push.runs.lock().unwrap().remove(session_id);
if !started.is_some_and(|t| t.elapsed() >= MIN_RUN_FOR_DONE) {
continue;
}
// Subagents report through their parent.
if app.store.get_session(session_id).ok().flatten().is_none_or(|s| s.parent_id.is_some()) {
continue;
}
(session_id.clone(), "done", String::new())
}
_ => continue,
};
let Some((owner, title, root)) = session_info(&app, &session_id) else { continue };
if app.push.is_visible(&owner) {
continue;
}
let payload = json!({
"kind": kind,
"title": title,
"body": body.chars().take(180).collect::<String>(),
"tag": format!("{root}-{kind}"),
"url": format!("/#/s/{root}"),
});
let app2 = app.clone();
tokio::spawn(async move {
send_to_user(&app2, &owner, &payload).await;
});
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Decrypt with the user agent's keys (RFC 8291) to check the encryption.
#[test]
fn roundtrip() {
let ua_secret = SecretKey::from_slice(&[7u8; 32]).unwrap();
let ua_public = ua_secret.public_key().as_affine().to_uncompressed_point();
let auth = [9u8; 16];
let msg = br#"{"title":"hi"}"#;
let body = encrypt(&ua_public, &auth, msg).unwrap();
let salt = &body[..16];
let idlen = body[20] as usize;
let as_public = &body[21..21 + idlen];
let ct = &body[21 + idlen..];
let as_pk = PublicKey::from_sec1_bytes(as_public).unwrap();
let shared = p256::ecdh::diffie_hellman(ua_secret.to_nonzero_scalar(), as_pk.as_affine());
let mut info = b"WebPush: info\0".to_vec();
info.extend_from_slice(&ua_public);
info.extend_from_slice(as_public);
let mut ikm = [0u8; 32];
Hkdf::<Sha256>::new(Some(&auth), shared.raw_secret_bytes()).expand(&info, &mut ikm).unwrap();
let prk = Hkdf::<Sha256>::new(Some(salt), &ikm);
let (mut cek, mut nonce) = ([0u8; 16], [0u8; 12]);
prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).unwrap();
prk.expand(b"Content-Encoding: nonce\0", &mut nonce).unwrap();
let pt = aes_gcm::Aes128Gcm::new_from_slice(&cek).unwrap().decrypt(&nonce.into(), ct).unwrap();
assert_eq!(&pt[..pt.len() - 1], msg);
assert_eq!(pt[pt.len() - 1], 2);
}
/// Example from RFC 8291, section 5.
#[test]
fn rfc8291_vector() {
let d = |s: &str| B64.decode(s).unwrap();
let as_secret = SecretKey::from_slice(&d("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw")).unwrap();
let ua_public = d("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4");
let auth = d("BTBZMqHH6r4Tts7J_aSIgg");
let salt: [u8; 16] = d("DGv6ra1nlYgDCS1FRnbzlw").try_into().unwrap();
let out = encrypt_with(&ua_public, &auth, b"When I grow up, I want to be a watermelon", &as_secret, salt).unwrap();
assert_eq!(
B64.encode(out),
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
);
}
#[test]
fn vapid_jwt_verifies() {
use p256::ecdsa::signature::Verifier;
let key = SecretKey::from_slice(&[3u8; 32]).unwrap();
let h = vapid_header(&key, "https://fcm.googleapis.com/fcm/send/abc", "mailto:a@b.c").unwrap();
let jwt = h.strip_prefix("vapid t=").unwrap().split(", k=").next().unwrap();
let (input, sig) = jwt.rsplit_once('.').unwrap();
let sig = p256::ecdsa::Signature::from_slice(&B64.decode(sig).unwrap()).unwrap();
p256::ecdsa::VerifyingKey::from(key.public_key()).verify(input.as_bytes(), &sig).unwrap();
let claims: Value = serde_json::from_slice(&B64.decode(input.split('.').nth(1).unwrap()).unwrap()).unwrap();
assert_eq!(claims["aud"], "https://fcm.googleapis.com");
}
}
+42 -2
View File
@@ -220,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
all.into_iter() all.into_iter()
.filter(|t| !agent.read_only || t.read_only()) .filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful. // Craft tools only where they are useful.
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some()) .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
.filter(|t| match &agent.tools { .filter(|t| match &agent.tools {
None => true, None => true,
Some(list) => { Some(list) => {
@@ -640,6 +640,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
todos: Value::Null, todos: Value::Null,
last_usage: None, last_usage: None,
clean_room: parent.clean_room, clean_room: parent.clean_room,
phase: 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 }]);
@@ -929,6 +930,45 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points."; const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
/// Suggest a title for a session from its conversation, with the given model
/// (or the small model). The title is returned, not saved.
pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<String>) -> Result<String> {
let session = app.session(session_id)?;
let spec = model_spec.filter(|m| !m.is_empty()).unwrap_or_else(|| small_model(&app, &session));
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let history = active_history(app.store.messages(session_id)?);
let mut transcript = transcript_of(&history);
if transcript.len() > 12_000 {
// Beginning (the goal) and end (where it went).
let head = clip(&transcript, 6_000);
let mut start = transcript.len() - 5_000;
while !transcript.is_char_boundary(start) {
start += 1;
}
transcript = format!("{head}\n…\n{}", &transcript[start..]);
}
if transcript.trim().is_empty() {
bail!("the session has no messages yet");
}
let req = ChatRequest {
system: "Write a short, specific title (max 6 words) for the coding session below. Use the language of the user's messages. Reply with the title only, no quotes, no punctuation at the end.".into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: format!("<session>\n{transcript}\n</session>") }])],
tools: vec![],
max_tokens: 2000,
reasoning: false,
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
record_side_usage(&app, &session, &spec, &model, &usage, "title");
let text = strip_think(&text);
let title = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ']).to_string();
if title.is_empty() {
bail!("the model returned no title");
}
Ok(title.chars().take(80).collect())
}
/// Remove a leading `<think>…</think>` block some models put into the text. /// Remove a leading `<think>…</think>` block some models put into the text.
fn strip_think(text: &str) -> String { fn strip_think(text: &str) -> String {
let t = text.trim_start(); let t = text.trim_start();
@@ -953,7 +993,7 @@ fn clip(s: &str, max: usize) -> String {
} }
/// The history as readable text for summarizing (tool output shortened). /// The history as readable text for summarizing (tool output shortened).
fn transcript_of(history: &[Message]) -> String { pub fn transcript_of(history: &[Message]) -> String {
let mut out = String::new(); let mut out = String::new();
for m in history { for m in history {
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" }; let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
+37
View File
@@ -97,6 +97,13 @@ impl Store {
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at); CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS audit ( CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
@@ -874,6 +881,30 @@ impl Store {
// --- audit log & backup ---------------------------------------------------------- // --- audit log & backup ----------------------------------------------------------
impl Store { impl Store {
// --- web push ---------------------------------------------------------
pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO push_subs (endpoint, user_id, p256dh, auth, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(endpoint) DO UPDATE SET user_id = ?2, p256dh = ?3, auth = ?4",
rusqlite::params![endpoint, user_id, p256dh, auth, now_ms()],
)?;
Ok(())
}
pub fn push_remove(&self, endpoint: &str) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM push_subs WHERE endpoint = ?1", [endpoint])?;
Ok(())
}
pub fn push_subscriptions(&self, user_id: &str) -> Result<Vec<PushSub>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT endpoint, p256dh, auth FROM push_subs WHERE user_id = ?1")?;
let rows = stmt.query_map([user_id], |r| Ok(PushSub { endpoint: r.get(0)?, p256dh: r.get(1)?, auth: r.get(2)? }))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) { pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let _ = conn.execute( let _ = conn.execute(
@@ -946,3 +977,9 @@ impl Store {
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64())) Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
} }
} }
pub struct PushSub {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
+42
View File
@@ -55,6 +55,48 @@ impl Tool for TodoWrite {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub const CRAFT_PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
/// Craft workflow: report the current phase (shown as a progress bar).
pub struct CraftPhase;
#[async_trait]
impl Tool for CraftPhase {
fn name(&self) -> &str {
"craft_phase"
}
fn description(&self) -> String {
"Report the craft phase you are entering (study, specify, build, check, ready). The user sees it \
as a progress bar. Call it at the start of every phase, also when you go back to an earlier one."
.into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {
"phase": {"type": "string", "enum": CRAFT_PHASES},
"note": {"type": "string", "description": "One short sentence: what happens in this phase"}
}, "required": ["phase"]})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Phase: {}", input["phase"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let phase = arg_str(&input, "phase")?;
if !CRAFT_PHASES.contains(&phase) {
bail!("unknown phase '{phase}' – use one of {}", CRAFT_PHASES.join(", "));
}
let root = ctx.app.root_session_id(&ctx.session_id);
let mut s = ctx.app.session(&root)?;
s.phase = Some(phase.to_string());
ctx.app.save_session(s)?;
Ok(ToolOutput::text(format!("Phase: {phase}")).with_meta(json!({"phase": phase, "note": input["note"]})))
}
}
// ---------------------------------------------------------------------------
pub struct AskUser; pub struct AskUser;
#[async_trait] #[async_trait]
+1
View File
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(crate::shells::KillShell), Arc::new(crate::shells::KillShell),
Arc::new(web::WebFetch), Arc::new(web::WebFetch),
Arc::new(web::WebSearch), Arc::new(web::WebSearch),
Arc::new(agent::CraftPhase),
Arc::new(crate::lsp::Diagnostics), Arc::new(crate::lsp::Diagnostics),
Arc::new(agent::TodoWrite), Arc::new(agent::TodoWrite),
Arc::new(agent::AskUser), Arc::new(agent::AskUser),
+3
View File
@@ -127,6 +127,9 @@ pub struct Session {
/// Craft mode: clean room (true) or deep (false); None = server default. /// Craft mode: clean room (true) or deep (false); None = server default.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub clean_room: Option<bool>, pub clean_room: Option<bool>,
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
} }
impl Session { impl Session {
+36
View File
@@ -0,0 +1,36 @@
// Web Push handling, imported into the generated service worker (workbox importScripts).
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { body: event.data ? event.data.text() : "" };
}
const title = data.title || "CodeCraft";
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
event.waitUntil(
self.registration.showNotification(prefix + title, {
body: data.kind === "done" ? data.body || "Finished" : data.body || "",
tag: data.tag || "codecraft",
renotify: true,
icon: "/icon-192.png",
badge: "/icon-192.png",
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
for (const client of list) {
if (new URL(client.url).origin === self.location.origin) {
return client.focus().then((c) => (c && "navigate" in c ? c.navigate(url) : c));
}
}
return self.clients.openWindow(url);
}),
);
});
+31
View File
@@ -62,6 +62,7 @@ export function connectEvents(handlers: {
ws = new WebSocket(`${proto}://${location.host}/api/ws`); ws = new WebSocket(`${proto}://${location.host}/api/ws`);
ws.onopen = () => { ws.onopen = () => {
retry = 0; retry = 0;
reportVisibility();
handlers.onOpen(); handlers.onOpen();
}; };
ws.onmessage = (m) => { ws.onmessage = (m) => {
@@ -80,7 +81,12 @@ export function connectEvents(handlers: {
}; };
open(); open();
// Reconnect immediately when the app comes back to the foreground (mobile PWA). // Reconnect immediately when the app comes back to the foreground (mobile PWA).
// The server sends push notifications only while no app window is visible.
const reportVisibility = () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "visibility", visible: document.visibilityState === "visible" }));
};
const onVisible = () => { const onVisible = () => {
reportVisibility();
if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) { if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) {
clearTimeout(timer); clearTimeout(timer);
open(); open();
@@ -94,3 +100,28 @@ export function connectEvents(handlers: {
ws?.close(); ws?.close();
}; };
} }
const CHUNK = 8 * 1024 * 1024;
/**
* Upload a file in 8 MB chunks (stays below reverse proxy body limits; up to the
* server's max_upload_mb). `onProgress` gets the uploaded fraction 0..1.
*/
export async function uploadFile(path: string, file: Blob, onProgress?: (fraction: number) => void): Promise<void> {
let offset = 0;
do {
const chunk = file.slice(offset, offset + CHUNK);
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}&offset=${offset}`, { method: "PUT", body: chunk, credentials: "same-origin" });
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
msg = (await res.json()).error ?? msg;
} catch {
if (res.status === 413) msg = "too large for the reverse proxy";
}
throw new Error(msg);
}
offset += chunk.size;
onProgress?.(file.size ? offset / file.size : 1);
} while (offset < file.size);
}
+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fmtCompact, useT } from "../i18n"; import { fmtCompact, useT } from "../i18n";
import { useStore, type Draft } from "../store"; import { useStore, type Draft } from "../store";
@@ -143,8 +144,8 @@ export function ActivityLine({
<span className="activity-text">{label}</span> <span className="activity-text">{label}</span>
{step && ( {step && (
<span className="activity-meta"> <span className="activity-meta">
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""} ({fmtElapsed(elapsed)} · <Icon name="arrow-up" className="tiny" /> {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`}) {fmtCompact(step.promptTokens)} {t("activity.prefill")} · <Icon name="arrow-down" className="tiny" /> {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
</span> </span>
)} )}
</div> </div>
@@ -163,8 +164,8 @@ export function useTitleActivity() {
const waiting = useStore((s) => s.permissions.length + s.questions.length); const waiting = useStore((s) => s.permissions.length + s.questions.length);
useEffect(() => { useEffect(() => {
const base = "CodeCraft"; const base = "CodeCraft";
if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`; if (waiting > 0) document.title = `(!) ${t("activity.titleInput")} – ${base}`;
else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`; else if (busyCount > 0) document.title = `(${busyCount}) ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
else document.title = base; else document.title = base;
}, [busyCount, waiting, t]); }, [busyCount, waiting, t]);
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
import { fmtCost, fmtPercent, useT } from "../i18n"; import { fmtCost, fmtPercent, useT } from "../i18n";
@@ -30,7 +31,7 @@ function Bar({ share, warning, exceeded }: { share: number; warning: boolean; ex
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) { function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
if (!r) return <span className="muted">–</span>; if (!r) return <span className="muted">–</span>;
if (r.error) return <span className="muted" title={r.error}>⚠ {t("budget.remoteError")}</span>; if (r.error) return <span className="muted" title={r.error}><Icon name="warning" /> {t("budget.remoteError")}</span>;
const cur = r.currency ?? "USD"; const cur = r.currency ?? "USD";
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>; if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>; if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
+53 -25
View File
@@ -1,8 +1,9 @@
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api"; import { del, get, patch, post } from "../api";
import { useModels } from "../hooks"; import { useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store"; import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types"; import type { Aggregate, ImageData, Message, Part, Session } from "../types";
@@ -10,6 +11,7 @@ import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs"; import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog"; import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { QueueList } from "./QueueList"; import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget"; import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction"; import { PermissionCard, QuestionCard } from "./Interaction";
@@ -47,7 +49,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}> <button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
{live ? <Dots /> : <ThinkIcon />} {live ? <Dots /> : <ThinkIcon />}
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span> <span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
<span aria-hidden="true">{open ? "▾" : "▸"}</span> <Icon name={open ? "chevron-down" : "chevron-right"} />
</button> </button>
{open && ( {open && (
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}> <div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
@@ -240,27 +242,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
{editingTitle ? ( <h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
<input
autoFocus
defaultValue={session.title}
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
style={{ width: "100%" }}
/>
) : (
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
{session.parent_id && ( {session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}> <a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
↩ <Icon name="undo" />
</a> </a>
)} )}
{session.title || t("session.untitled")} {session.title || t("session.untitled")}
</h1> {!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
<Icon name="edit" />
</button>
)} )}
</h1>
<div className="path">{session.cwd}</div> <div className="path">{session.cwd}</div>
</div> </div>
{ctxShare !== undefined && ( {ctxShare !== undefined && (
@@ -271,8 +268,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{stats && ( {stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}> <button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
<span className="hide-mobile"> <span className="hide-mobile">
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "} {fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
{fmtCompact(stats.total.output_tokens)}↓ ·{" "} {fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
</span> </span>
{fmtCost(stats.total.cost, stats.currency)} {fmtCost(stats.total.cost, stats.currency)}
</button> </button>
@@ -285,17 +282,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<> <>
{!session.parent_id && ( {!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}> <button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
⇄ <Icon name="share" />
</button> </button>
)} )}
{!session.parent_id && ( {!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}> <button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
🔀 <Icon name="fork" />
</button> </button>
)} )}
<details className="menu-pop"> <details className="menu-pop">
<summary className="small ghost" title={t("chat.export")}> <summary className="small ghost" title={t("chat.export")}>
💾 <Icon name="download" />
</summary> </summary>
<div className="menu-pop-body"> <div className="menu-pop-body">
<a href={`/api/sessions/${sessionId}/export?format=md`} download> <a href={`/api/sessions/${sessionId}/export?format=md`} download>
@@ -307,14 +304,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</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" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊ <Icon name="compact" />
</button> </button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}> <button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑 <Icon name="trash" />
</button> </button>
</> </>
)} )}
</div> </div>
{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
className="chat-scroll" className="chat-scroll"
@@ -375,7 +373,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!busy && !readOnly && ( {!busy && !readOnly && (
<div className="msg-actions"> <div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}> <button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
✎ <Icon name="edit" />
</button> </button>
</div> </div>
)} )}
@@ -417,7 +415,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<div key={e.id} className="error-box" role="alert"> <div key={e.id} className="error-box" role="alert">
<span>{e.message}</span> <span>{e.message}</span>
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}> <button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
× <Icon name="close" />
</button> </button>
</div> </div>
))} ))}
@@ -438,11 +436,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!following && ( {!following && (
<div className="jump-latest"> <div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}> <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
↓ <Icon name="arrow-down" />
</button> </button>
</div> </div>
)} )}
</div> </div>
{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 && <QueueList sessionId={sessionId} busy={busy} />} {!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
@@ -500,3 +499,32 @@ function fmtK(n: number) {
if (n >= 1000) return `${Math.round(n / 1000)}k`; if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n); return String(n);
} }
/** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */
function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
const t = useT();
const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"];
const cur = phase ? phases.indexOf(phase) : -1;
return (
<div className="phase-bar" aria-label="Craft progress">
{phases.map((p, i) => {
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
return (
<span key={p} style={{ display: "contents" }}>
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
<span className={`phase ${state}`}>
<span className="dot">
{state === "done" && (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</span>
{t(`phase.${p}` as Key)}
</span>
</span>
);
})}
</div>
);
}
+11 -8
View File
@@ -1,4 +1,6 @@
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure"; import { formatSize } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks"; import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n"; import { useT, type Key } from "../i18n";
@@ -20,7 +22,7 @@ const MAX_SIDE = 2000;
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */ /** Read an image and downscale it: models don't use more detail, and uploads stay small. */
async function readImage(file: File): Promise<ImageData> { export async function readImage(file: File): Promise<ImageData> {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height)); const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@@ -179,8 +181,9 @@ export function Composer({
await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) }); await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) });
// Keep uploads out of git status without touching the project's .gitignore. // Keep uploads out of git status without touching the project's .gitignore.
await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" }); await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" });
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}`, { method: "PUT", body: f, credentials: "same-origin" }); await uploadFile(path, f).catch((e) => {
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`); throw new Error(`${f.name}: ${e.message}`);
});
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]); setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
} catch (e: any) { } catch (e: any) {
toast(e.message ?? String(e)); toast(e.message ?? String(e));
@@ -229,10 +232,10 @@ export function Composer({
<div className="attachments"> <div className="attachments">
{files.map((f) => ( {files.map((f) => (
<div className="att file" key={f.path} title={f.path}> <div className="att file" key={f.path} title={f.path}>
<span>📄 {f.name}</span> <span><Icon name="file" /> {f.name}</span>
<small>{formatSize(f.size)}</small> <small>{formatSize(f.size)}</small>
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}> <button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
× <Icon name="close" />
</button> </button>
</div> </div>
))} ))}
@@ -245,7 +248,7 @@ export function Composer({
<div className="att" key={i}> <div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" /> <img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
× <Icon name="close" />
</button> </button>
</div> </div>
))} ))}
@@ -310,14 +313,14 @@ export function Composer({
</select> </select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} /> <ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}> <label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
📎 <Icon name="clip" />
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} /> <input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
</label> </label>
</div> </div>
<div className="composer-actions"> <div className="composer-actions">
{busy && ( {busy && (
<button className="danger" onClick={onAbort} title="Esc"> <button className="danger" onClick={onAbort} title="Esc">
■ {t("composer.stop")} <Icon name="stop" /> {t("composer.stop")}
</button> </button>
)} )}
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}> <button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
/** Human-readable file size. */ /** Human-readable file size. */
@@ -137,7 +138,7 @@ export function FileChip({ url, name, size, sizeText }: { url?: string; name: st
const type = fileType(name, meta.mime); const type = fileType(name, meta.mime);
return ( return (
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}> <a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
<span className="file-chip-icon">📎</span> <Icon name="clip" className="file-chip-icon" />
<span className="file-chip-name">{basename(name)}</span> <span className="file-chip-name">{basename(name)}</span>
{type && <span className="file-badge">{type}</span>} {type && <span className="file-badge">{type}</span>}
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>} {(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
+20 -19
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { del, get, post, put } from "../api"; import { del, get, post, put } from "../api";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
@@ -45,13 +46,13 @@ const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/"; const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) { function icon(e: Entry) {
if (e.is_dir) return "📁"; if (e.is_dir) return <Icon name="folder" className="ficon dir" />;
if (IMAGE.test(e.name)) return "🖼"; if (IMAGE.test(e.name)) return <Icon name="image" className="ficon" />;
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵"; if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return <Icon name="music" className="ficon" />;
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊"; if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return <Icon name="cube" className="ficon" />;
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑"; if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return <Icon name="file-doc" className="ficon" />;
if (/\.(md|txt)$/i.test(e.name)) return "📝"; if (/\.(md|txt)$/i.test(e.name)) return <Icon name="file-text" className="ficon" />;
return "📄"; return <Icon name="file" className="ficon" />;
} }
export function Files({ onMenu }: { onMenu: () => void }) { export function Files({ onMenu }: { onMenu: () => void }) {
@@ -243,13 +244,13 @@ export function Files({ onMenu }: { onMenu: () => void }) {
)} )}
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} /> <input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}> <button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
+ 📄 <Icon name="plus" className="tiny" /> <Icon name="file" />
</button> </button>
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}> <button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
+ 📁 <Icon name="plus" className="tiny" /> <Icon name="folder" />
</button> </button>
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}> <button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
⤒ <Icon name="upload" />
</button> </button>
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} /> <input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}> <button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
@@ -277,7 +278,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
) : ( ) : (
hits.map((h) => ( hits.map((h) => (
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}> <button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
<span>{h.is_dir ? "📁" : "📄"}</span> <Icon name={h.is_dir ? "folder" : "file"} className="ficon" />
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span> <span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
</button> </button>
)) ))
@@ -286,7 +287,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<> <>
{listing?.parent && ( {listing?.parent && (
<button className="entry" onClick={() => load(listing.parent!)}> <button className="entry" onClick={() => load(listing.parent!)}>
<span>↰</span> <Icon name="folder-up" className="ficon" />
<span className="name">..</span> <span className="name">..</span>
</button> </button>
)} )}
@@ -298,11 +299,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}> <button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
<span>{icon(e)}</span> <span>{icon(e)}</span>
<span className="name">{e.name}</span> <span className="name">{e.name}</span>
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>} {mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark === "•" ? <Icon name="dot" className="tiny" /> : mark}</span>}
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>} {!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
</button> </button>
<details className="entry-menu"> <details className="entry-menu">
<summary aria-label={t("files.actions")}>⋯</summary> <summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu"> <div className="menu">
<button onClick={() => rename(e)}>{t("files.rename")}</button> <button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button> <button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
@@ -333,7 +334,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane"> <div className="editor-pane">
<div className="editor-bar"> <div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}> <button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
← <Icon name="arrow-left" />
</button> </button>
<b className="grow">{pane.title}</b> <b className="grow">{pane.title}</b>
</div> </div>
@@ -349,11 +350,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane"> <div className="editor-pane">
<div className="editor-bar"> <div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}> <button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
← <Icon name="arrow-left" />
</button> </button>
<b className="grow" title={f.path}> <b className="grow" title={f.path}>
{baseName(f.path)} {baseName(f.path)}
{dirty && <span className="dirty"> ●</span>} {dirty && <span className="dirty"> <Icon name="dot" className="tiny" /></span>}
</b> </b>
{(isMd || isImage) && !f.preview && ( {(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}> <button className="small" onClick={() => setPreview(!preview)}>
@@ -361,7 +362,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button> </button>
)} )}
<a className="button small" href={rawUrl(f.path, true)}> <a className="button small" href={rawUrl(f.path, true)}>
⤓ <Icon name="download" />
</a> </a>
{!f.binary && ( {!f.binary && (
<button className="small primary" disabled={!dirty} onClick={() => save()}> <button className="small primary" disabled={!dirty} onClick={() => save()}>
@@ -400,7 +401,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
<h1>{t("nav.files")}</h1> <h1>{t("nav.files")}</h1>
+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { post } from "../api"; import { post } from "../api";
import { fmtRelative, useT } from "../i18n"; import { fmtRelative, useT } from "../i18n";
@@ -104,17 +105,17 @@ export function GitPanel({
{f.path} {f.path}
</button> </button>
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}> <button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
↗ <Icon name="external" />
</button> </button>
{isStaged ? ( {isStaged ? (
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}> <button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
− <Icon name="minus" />
</button> </button>
) : ( ) : (
<> <>
{f.index !== "?" && ( {f.index !== "?" && (
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}> <button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
↺ <Icon name="refresh" />
</button> </button>
)} )}
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}> <button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
@@ -153,7 +154,7 @@ export function GitPanel({
</button> </button>
{status.upstream && ( {status.upstream && (
<span className="chip" title={status.upstream}> <span className="chip" title={status.upstream}>
↑{status.ahead} ↓{status.behind} <Icon name="arrow-up" className="tiny" />{status.ahead} <Icon name="arrow-down" className="tiny" />{status.behind}
</span> </span>
)} )}
<span className="spacer" /> <span className="spacer" />
+229
View File
@@ -0,0 +1,229 @@
import type { ReactNode } from "react";
/**
* Line icons drawn with `currentColor`: they take the text colour of their
* context, so every theme colours them (no per-theme icon sets needed).
*/
const P: Record<string, ReactNode> = {
menu: <path d="M4 6h16M4 12h16M4 18h16" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
check: <path d="m5 12.5 4.5 4.5L19 7" />,
"x-mark": <path d="M7 7l10 10M17 7 7 17" />,
"arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />,
"arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />,
"arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />,
"chevron-right": <path d="m9 6 6 6-6 6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />,
share: (
<>
<circle cx="18" cy="5.5" r="2.5" />
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="18.5" r="2.5" />
<path d="m8.2 10.8 7.6-4M8.2 13.2l7.6 4" />
</>
),
fork: (
<>
<circle cx="6" cy="5" r="2" />
<circle cx="18" cy="5" r="2" />
<circle cx="12" cy="19" r="2" />
<path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5" />
</>
),
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
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" />,
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" />,
file: (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4" />
</>
),
"file-text": (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4M9 12h6M9 16h6" />
</>
),
"file-doc": (
<>
<path d="M6 3h8l4 4v14H6Z" />
<path d="M14 3v4h4M9 11h6M9 14h6M9 17h4" />
</>
),
folder: <path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />,
"folder-up": (
<>
<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />
<path d="M12 16v-5M9.5 13.5 12 11l2.5 2.5" />
</>
),
folders: (
<>
<path d="M7 8a1 1 0 0 1 1-1h4l1.5 1.5H20a1 1 0 0 1 1 1V18a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1Z" />
<path d="M4 15V6a1 1 0 0 1 1-1h4" />
</>
),
image: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="9" cy="10" r="1.8" />
<path d="m4 17 5-4 4 3 3-2 4 3" />
</>
),
music: (
<>
<path d="M9 18V6l11-2v12" />
<circle cx="6.5" cy="18" r="2.5" />
<circle cx="17.5" cy="16" r="2.5" />
</>
),
cube: (
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>
),
external: <path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" />,
refresh: <path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" />,
lock: (
<>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3M12 15v2" />
</>
),
question: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5V14M12 17h.01" />
</>
),
"radio-on": (
<>
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3.5" className="fill" />
</>
),
"radio-off": <circle cx="12" cy="12" r="8" />,
"box-on": (
<>
<rect x="4" y="4" width="16" height="16" rx="3" />
<path d="m8 12 3 3 5-6" />
</>
),
"box-off": <rect x="4" y="4" width="16" height="16" rx="3" />,
play: <path d="M8 5v14l11-7Z" className="fill" />,
stop: <rect x="6" y="6" width="12" height="12" rx="2" className="fill" />,
warning: <path d="M12 4 2.5 20h19ZM12 10v4.5M12 17.5h.01" />,
brain: (
<>
<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1V5a2 2 0 0 0-3-1Z" />
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
</>
),
eye: (
<>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</>
),
gear: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1M18.7 18.7l-2.1-2.1M7.4 7.4 5.3 5.3" />
</>
),
robot: (
<>
<rect x="5" y="8" width="14" height="11" rx="3" />
<path d="M12 4v4M9 13h.01M15 13h.01M9.5 16h5M3 12v3M21 12v3" />
</>
),
plug: <path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0ZM12 17v4" />,
shield: (
<>
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
<path d="m9 12 2 2 4-4" />
</>
),
users: (
<>
<circle cx="9" cy="8" r="3.2" />
<path d="M3 20a6 6 0 0 1 12 0M16 4.5a3.2 3.2 0 0 1 0 6.3M18 14.5a6 6 0 0 1 3 5.5" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="3.8" />
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
</>
),
toolbox: (
<>
<rect x="3" y="8" width="18" height="11" rx="2" />
<path d="M9 8V5h6v3M3 13h18M10 12v2M14 12v2" />
</>
),
scroll: <path d="M7 4h11a2 2 0 0 1 0 4h-1v10a2 2 0 0 1-2 2H6a2 2 0 0 1 0-4h1ZM10 9h4M10 12h4" />,
terminal: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="m7 9 3 3-3 3M12 15h5" />
</>
),
logout: <path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5M15 8l4 4-4 4M19 12H9" />,
chart: <path d="M4 20V4M4 20h16M8 16v-4M12 16V8M16 16v-6M20 16V6" />,
wrench: <path d="M14.5 5.5a4 4 0 0 0 5 5L11 19a2.1 2.1 0 0 1-3-3l8.5-8.5a4 4 0 0 1-2-2Z" />,
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M21.5 12h-2M4.5 12h-2M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4M18.7 18.7l-1.4-1.4M6.7 6.7 5.3 5.3" />
</>
),
moon: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />,
"theme-auto": (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 3.5v17a8.5 8.5 0 0 0 0-17Z" className="fill" />
</>
),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />,
minus: <path d="M5 12h14" />,
upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />,
more: (
<>
<circle cx="5" cy="12" r="1.6" className="fill" />
<circle cx="12" cy="12" r="1.6" className="fill" />
<circle cx="19" cy="12" r="1.6" className="fill" />
</>
),
copy: (
<>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" />
</>
),
sparkle: <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />,
};
export type IconName = keyof typeof P;
export function Icon({ name, size, className, title }: { name: string; size?: number; className?: string; title?: string }) {
return (
<svg
className={`icon ${className ?? ""}`}
viewBox="0 0 24 24"
width={size}
height={size}
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
>
{title && <title>{title}</title>}
{P[name] ?? P.dot}
</svg>
);
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { del, post } from "../api"; import { del, post } from "../api";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
@@ -63,7 +64,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
<h1>{t("nav.images")}</h1> <h1>{t("nav.images")}</h1>
@@ -141,16 +142,16 @@ export function Images({ onMenu }: { onMenu: () => void }) {
</div> </div>
<div className="row" style={{ gap: 4 }}> <div className="row" style={{ gap: 4 }}>
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}> <a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
⤓ <Icon name="download" />
</a> </a>
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}> <button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
↺ <Icon name="refresh" />
</button> </button>
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}> <button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
⧉ <Icon name="copy" />
</button> </button>
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}> <button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
🗑 <Icon name="trash" />
</button> </button>
</div> </div>
</figcaption> </figcaption>
+4 -3
View File
@@ -1,3 +1,4 @@
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 { useT } from "../i18n";
@@ -43,7 +44,7 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
return ( return (
<div className="card" role="alertdialog" aria-label={t("permission.title")}> <div className="card" role="alertdialog" aria-label={t("permission.title")}>
<h3> <h3>
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span> <Icon name="lock" /> {t("permission.title")} <span className="chip mono">{req.tool}</span>
</h3> </h3>
<SubagentHint sessionId={req.session_id} currentId={currentId} /> <SubagentHint sessionId={req.session_id} currentId={currentId} />
<div style={{ margin: "6px 0" }}>{req.title}</div> <div style={{ margin: "6px 0" }}>{req.title}</div>
@@ -122,7 +123,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current
return ( return (
<div className="card" role="dialog" aria-label={t("question.title")}> <div className="card" role="dialog" aria-label={t("question.title")}>
<h3>❓ {t("question.title")}</h3> <h3><Icon name="question" /> {t("question.title")}</h3>
<div className="from">{source}</div> <div className="from">{source}</div>
<SubagentHint sessionId={req.session_id} currentId={currentId} /> <SubagentHint sessionId={req.session_id} currentId={currentId} />
{req.questions.map((q, qi) => ( {req.questions.map((q, qi) => (
@@ -143,7 +144,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current
onClick={() => toggle(qi, o.label, !!q.multi_select)} onClick={() => toggle(qi, o.label, !!q.multi_select)}
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)} onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
> >
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span> <Icon name={q.multi_select ? (isSel ? "box-on" : "box-off") : isSel ? "radio-on" : "radio-off"} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
{o.label === OTHER ? ( {o.label === OTHER ? (
<input <input
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { memo, useState, type ReactNode } from "react"; import { memo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -36,7 +37,7 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
<img src={url} alt="SVG" /> <img src={url} alt="SVG" />
</FileFigure> </FileFigure>
<button className="small ghost" onClick={() => setShowCode(!showCode)}> <button className="small ghost" onClick={() => setShowCode(!showCode)}>
{showCode ? "▾ SVG" : "▸ SVG"} <Icon name={showCode ? "chevron-down" : "chevron-right"} /> SVG
</button> </button>
{showCode && children} {showCode && children}
</div> </div>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM). // 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
// Loaded on demand only when such a file is opened. // Loaded on demand only when such a file is opened.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -147,7 +148,7 @@ export function ModelViewer({ url, ext }: { url: string; ext: string }) {
return ( return (
<div className="model-viewer"> <div className="model-viewer">
<div ref={host} className="model-canvas" /> <div ref={host} className="model-canvas" />
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div> <div className="model-info">{state.loading ? "…" : state.error ? <><Icon name="warning" /> {state.error}</> : state.info}</div>
</div> </div>
); );
} }
+533 -68
View File
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react"; import { Icon } from "./Icon";
import { post } from "../api"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { post, uploadFile } from "../api";
import { useAgents, useFetch } from "../hooks"; import { useAgents, useFetch } from "../hooks";
import { useT } from "../i18n"; import { useT, type Key } from "../i18n";
import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize } from "./FileFigure";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
interface DirListing { interface DirListing {
@@ -14,64 +18,560 @@ interface DirListing {
const LAST_DIR = "codecraft.lastDir"; const LAST_DIR = "codecraft.lastDir";
type Task = "build" | "plan" | "craft" | string;
type Target = "website" | "app" | "game" | "software" | "fresh";
// --- icons (SVG: identical on every system) -------------------------------------
const svg = (children: ReactNode) => (
<svg viewBox="0 0 24 24" className="wiz-icon" aria-hidden="true">
{children}
</svg>
);
const ICONS: Record<string, ReactNode> = {
build: svg(<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16" />),
plan: svg(
<>
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
<path d="M9 4v14M15 6v14" />
</>,
),
craft: svg(
<>
<path d="m4 20 10-10M14 10l2-2" />
<path d="M17 3v3M15.5 4.5h3M20 8v2M19 9h2M11 3v2M10 4h2" />
</>,
),
agent: svg(
<>
<rect x="5" y="7" width="14" height="11" rx="3" />
<path d="M12 3v4M9 12h.01M15 12h.01M9.5 15h5" />
</>,
),
website: svg(
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 3.5 5.8 3.5 9s-1 6.2-3.5 9c-2.5-2.8-3.5-5.8-3.5-9s1-6.2 3.5-9Z" />
</>,
),
app: svg(
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 9h18M6 7h.01M8.5 7h.01" />
</>,
),
game: svg(
<>
<path d="M7 9h10a4 4 0 0 1 4 4v1a3 3 0 0 1-5.4 1.8L14.5 14h-5l-1.1 1.8A3 3 0 0 1 3 14v-1a4 4 0 0 1 4-4Z" />
<path d="M8 11.5v3M6.5 13h3M15.5 12h.01M17.5 13.5h.01" />
</>,
),
software: svg(
<>
<path d="M5 3h11l3 3v15H5Z" />
<path d="M8 3v5h7V3M8 21v-7h8v7" />
</>,
),
fresh: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z" />),
clean: svg(
<>
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
<path d="m9 12 2 2 4-4" />
</>,
),
deep: svg(
<>
<circle cx="11" cy="11" r="6" />
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
</>,
),
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
git: svg(
<>
<circle cx="6" cy="6" r="2.2" />
<circle cx="6" cy="18" r="2.2" />
<circle cx="18" cy="9" r="2.2" />
<path d="M6 8.2v7.6M18 11.2c0 3.5-4 3-9.6 5.6" />
</>,
),
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
clip: svg(<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" />),
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
};
function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) {
return (
<button type="button" className={`wiz-card ${active ? "active" : ""}`} onClick={onClick} aria-pressed={active}>
{ICONS[icon] ?? ICONS.agent}
<span className="wiz-card-text">
<span className="wiz-card-title">{title}</span>
{desc && <span className="wiz-card-desc">{desc}</span>}
</span>
{active && (
<svg viewBox="0 0 24 24" className="wiz-check" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</button>
);
}
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<section className="wiz-step">
<h3>
<span className="wiz-num">{n}</span>
{title}
</h3>
{children}
</section>
);
}
const slug = (s: string) =>
s
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/https?:\/\/(www\.)?/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.split("-")
.slice(0, 4)
.join("-")
.slice(0, 40);
async function api(path: string, init: RequestInit) {
const r = await fetch(`/api${path}`, { credentials: "same-origin", ...init });
if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`);
return r;
}
/** Guided start: what to do → (craft: what to recreate, clean room/deep) → describe → model & folder. */
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) { export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
const t = useT(); const t = useT();
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? []; const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
const config = useFetch<{ default_model?: string }>("/config").data; const config = useFetch<{ default_model?: string }>("/config").data;
const [task, setTask] = useState<Task>("craft");
const [target, setTarget] = useState<Target>("fresh");
const [deep, setDeep] = useState(false);
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [images, setImages] = useState<ImageData[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [agent, setAgent] = useState("build"); // Folder: a new one (name) inside a root, or an existing one (picked).
const [prompt, setPrompt] = useState(""); const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new");
const [repoUrl, setRepoUrl] = useState("");
// Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks.
const [originals, setOriginals] = useState<File[]>([]);
const [progress, setProgress] = useState<Record<string, number>>({});
const originalInput = useRef<HTMLInputElement>(null);
const [folderName, setFolderName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [rootPath, setRootPath] = useState("");
const [picked, setPicked] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState<string>();
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary"); const fileInput = useRef<HTMLInputElement>(null);
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
useEffect(() => { useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model); if (!model && config?.default_model) setModel(config.default_model);
}, [config, model]); }, [config, model]);
// A remembered directory that vanished: fall back to the workspace root.
useEffect(() => { useEffect(() => {
if (dirError && path) setPath(""); if (!rootPath && roots.length) setRootPath(roots[roots.length - 1].path);
}, [dirError, path]); }, [roots, rootPath]);
// Old software is analysed in deep mode; websites default to clean room.
useEffect(() => {
if (target === "software") setDeep(true);
else if (target === "website") setDeep(false);
}, [target]);
// Working on existing code usually happens in an existing folder.
useEffect(() => {
setFolderMode((m) => (m === "git" ? m : task === "craft" ? "new" : "existing"));
}, [task]);
const create = async () => { const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
if (!listing) return; const autoName = useMemo(
setCreating(true); () => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"),
setError(undefined); [text, url, target, task, folderMode, repoName],
);
useEffect(() => {
if (!nameTouched) setFolderName(autoName);
}, [autoName, nameTouched]);
const ideas: Key[] =
task !== "craft"
? task === "plan"
? ["wizard.idea.planFeature", "wizard.idea.planRefactor"]
: ["wizard.idea.buildTests", "wizard.idea.buildBug"]
: ({
website: ["wizard.idea.siteLanding", "wizard.idea.siteShop"],
app: ["wizard.idea.appTodo", "wizard.idea.appNotes"],
game: ["wizard.idea.gameSnake", "wizard.idea.gameBreakout"],
software: ["wizard.idea.swDos", "wizard.idea.swTool"],
fresh: ["wizard.idea.freshBakery", "wizard.idea.freshSnake"],
} as Record<Target, Key[]>)[target];
const addFiles = async (list: FileList | File[]) => {
const all = [...list];
const imgs = all.filter((f) => f.type.startsWith("image/") && target !== "software");
try { try {
localStorage.setItem(LAST_DIR, listing.path); const read = await Promise.all(imgs.map(readImage));
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent }); setImages((cur) => [...cur, ...read]);
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
onCreated(s.id);
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
setCreating(false); }
setFiles((cur) => [...cur, ...all.filter((f) => !imgs.includes(f))]);
};
const needsUrl = task === "craft" && target === "website";
const isRecreate = task === "craft" && target === "software";
const canStart =
!creating &&
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
/** The first message(s) for the agent. */
const prompts = (uploaded: string[]): string[] => {
const desc = text.trim();
const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : "";
if (task !== "craft") return [desc + fileList];
switch (target) {
case "website":
return [`Recreate the website ${url.trim()} as a new project in this folder.${desc ? `\n\n${desc}` : ""}${fileList}`];
case "app":
case "game":
return [
`Recreate this ${target === "app" ? "app" : "game"} as a new project in this folder${images.length ? " – screenshots are attached" : ""}.${desc ? `\n\n${desc}` : ""}${fileList}`,
];
case "software":
return [
`/recreate-init The program files are already in original/ (${uploaded.length ? uploaded.join(", ") : "added by the user"}). Set up the layout around them without moving them.`,
`/recreate-start ${desc}`,
];
default:
return [`Build this from scratch (there is no original to study – start with a short plan, then build, check and refine):\n\n${desc}${fileList}`];
} }
}; };
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : ""; const create = async () => {
const currentRoot = listing?.root; setError(undefined);
try {
// 1. Folder
let cwd = picked;
if (folderMode === "git") {
setCreating(t("wizard.cloning"));
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
cwd = target;
} else if (folderMode === "new") {
setCreating(t("wizard.creatingFolder"));
const base = folderName.trim().replace(/[\\/]/g, "-");
for (let i = 0; i < 50; i++) {
const candidate = `${rootPath}/${i ? `${base}-${i + 1}` : base}`;
try {
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: candidate, dir: true }) });
cwd = candidate;
break;
} catch (e: any) {
if (!/exists/i.test(e.message)) throw e;
}
}
if (!cwd) throw new Error("could not create the folder");
}
localStorage.setItem(LAST_DIR, cwd);
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
const uploaded: string[] = [];
const mkdirs = async (dir: string) => {
let parent = cwd;
for (const part of dir.split("/")) {
parent = `${parent}/${part}`;
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: parent, dir: true }) }).catch(() => undefined);
}
};
const send = async (list: File[], dir: string) => {
if (!list.length) return;
await mkdirs(dir);
for (const f of list) {
const name = f.name.replace(/[\\/]/g, "_");
setCreating(`${t("wizard.uploading")} ${name}`);
await uploadFile(`${cwd}/${dir}/${name}`, f, (x) => setProgress((p) => ({ ...p, [f.name]: x })));
uploaded.push(`${dir}/${name}`);
}
};
// Originals of old software go to original/, everything else to .codecraft/uploads/.
await send(originals, "original");
await send(files, isRecreate ? "original" : ".codecraft/uploads");
// 3. Session and first prompt(s)
setCreating(t("wizard.starting"));
const isCraft = task === "craft";
const s = await post<{ id: string }>("/sessions", {
cwd,
model: model || undefined,
agent: task,
clean_room: isCraft ? !deep : undefined,
});
const msgs = prompts(uploaded).filter((m) => m.trim());
for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] });
onCreated(s.id);
} catch (e: any) {
setError(e.message);
setCreating(undefined);
}
};
let n = 1;
return ( return (
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
<h1>{t("new.title")}</h1> <h1>{t("new.title")}</h1>
</div> </div>
</div> </div>
<div className="page"> <div className="page">
<div className="page-inner" style={{ maxWidth: 760 }}> <div className="page-inner wizard">
<div className="panel"> <h2 className="wiz-title">{t("wizard.title")}</h2>
<h3>{t("new.directory")}</h3> <p className="wiz-sub">{t("wizard.subtitle")}</p>
<div className="panel wiz-panel">
<Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards">
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
{custom.map((a) => (
<Card key={a.name} icon="agent" title={a.name} desc={a.description} active={task === a.name} onClick={() => setTask(a.name)} />
))}
</div>
</Step>
{task === "craft" && (
<Step n={n++} title={t("wizard.whatToRecreate")}>
<div className="wiz-cards five">
{(["website", "app", "game", "software", "fresh"] as Target[]).map((x) => (
<Card key={x} icon={x} title={t(`wizard.target.${x}` as Key)} active={target === x} onClick={() => setTarget(x)} />
))}
</div>
{target !== "fresh" && (
<div className="wiz-cards two" style={{ marginTop: 12 }}>
<Card icon="clean" title={t("craft.cleanRoom")} desc={t("wizard.cleanDesc")} active={!deep} onClick={() => setDeep(false)} />
<Card icon="deep" title={t("craft.deep")} desc={t("wizard.deepDesc")} active={deep} onClick={() => setDeep(true)} />
</div>
)}
{target === "software" && !deep && <div className="notice">{t("wizard.softwareNeedsDeep")}</div>}
{target === "software" && (
<div
className="wiz-drop"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
setOriginals((c) => [...c, ...e.dataTransfer.files]);
}}
onClick={() => originalInput.current?.click()}
role="button"
tabIndex={0}
>
{ICONS.upload}
<div>
<b>{t("wizard.originalTitle")}</b>
<div className="hint">{t("wizard.originalHint")}</div>
</div>
<input
ref={originalInput}
type="file"
multiple
hidden
onChange={(e) => {
if (e.target.files) setOriginals((c) => [...c, ...e.target.files!]);
e.target.value = "";
}}
/>
</div>
)}
{target === "software" && originals.length > 0 && (
<div className="wiz-originals">
{originals.map((f, i) => (
<div key={`${f.name}${i}`} className="wiz-original">
<span className="name">{f.name}</span>
<span className="size">{formatSize(f.size)}</span>
{progress[f.name] !== undefined ? (
<span className="bar">
<i style={{ width: `${Math.round(progress[f.name] * 100)}%` }} />
</span>
) : (
<button className="small ghost" onClick={() => setOriginals((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
)}
</div>
))}
</div>
)}
</Step>
)}
<Step n={n++} title={t("wizard.sayWhat")}>
{needsUrl && (
<input
className="wiz-url"
type="url"
value={url}
placeholder="https://example.com"
onChange={(e) => setUrl(e.target.value)}
/>
)}
<div
className="wiz-input"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
void addFiles(e.dataTransfer.files);
}}
>
<textarea
value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
e.preventDefault();
void addFiles(e.clipboardData.files);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !matchMedia("(pointer: coarse)").matches && canStart) {
e.preventDefault();
void create();
}
}}
/>
{(images.length > 0 || files.length > 0) && (
<div className="attachments">
{images.map((img, i) => (
<div className="att" key={`i${i}`}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
</div>
))}
{files.map((f, i) => (
<div className="att file" key={`f${i}`}>
<span><Icon name="file" /> {f.name}</span>
<small>{formatSize(f.size)}</small>
<button onClick={() => setFiles((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
</div>
))}
</div>
)}
<div className="wiz-input-foot">
<button type="button" className="ghost small" onClick={() => fileInput.current?.click()} title={t("wizard.attach")}>
{ICONS.clip}
</button>
<span>{target === "software" && task === "craft" ? t("wizard.softwareHint") : t("wizard.enterHint")}</span>
<input ref={fileInput} type="file" multiple hidden onChange={(e) => e.target.files && void addFiles(e.target.files)} />
</div>
</div>
{ideas && (
<div className="wiz-ideas">
<span>{t("wizard.ideas")}</span>
{ideas.map((k) => (
<button key={k} type="button" className="chip-btn" onClick={() => setText(t(k))}>
{t(k)}
</button>
))}
</div>
)}
</Step>
<div className="wiz-foot">
<div className="wiz-foot-row">
<label>{t("wizard.builtBy")}</label>
<ModelSelect value={model} onChange={setModel} />
</div>
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
{(["new", "existing", "git"] as const).map((m) => (
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
{ICONS[m === "git" ? "git" : "folder"]}
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
</button>
))}
</div>
{folderMode === "git" && (
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
)}
{folderMode !== "existing" && (
<div className="wiz-folder">
{ICONS.folder}
<span className="wiz-folder-text">
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
{roots.length > 1 ? (
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name === "/" ? "workspace" : r.name}
</option>
))}
</select>
) : (
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
)}
<span>/</span>
<input
className="wiz-folder-name"
value={folderName}
onChange={(e) => {
setFolderName(e.target.value);
setNameTouched(true);
}}
/>
</span>
</div>
)}
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
{error && <div className="notice err">{error}</div>}
<div className="wiz-start-row">
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
{ICONS.start}
{creating ?? t("wizard.start")}
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
/** Browse the user's folders and pick one. */
function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
const t = useT();
const [browse, setBrowse] = useState(path);
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const { data: listing, error } = useFetch<DirListing>(`/fs/dirs${browse ? `?path=${encodeURIComponent(browse)}` : ""}`);
useEffect(() => {
if (error && browse) setBrowse("");
}, [error, browse]);
useEffect(() => {
if (listing && listing.path !== path) onPick(listing.path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing?.path]);
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
return (
<div className="wiz-picker">
{roots.length > 1 && ( {roots.length > 1 && (
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}> <div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
{roots.map((r) => ( {roots.map((r) => (
<button key={r.path} className={currentRoot === r.path ? "active" : ""} onClick={() => setPath(r.path)}> <button key={r.path} className={listing?.root === r.path ? "active" : ""} onClick={() => setBrowse(r.path)}>
{r.name} {r.name}
</button> </button>
))} ))}
@@ -82,56 +582,21 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
{rel} {rel}
</code> </code>
{listing?.parent && ( {listing?.parent && (
<button className="small" onClick={() => setPath(listing.parent!)}> <button className="small" onClick={() => setBrowse(listing.parent!)}>
↑ {t("new.up")} <Icon name="arrow-up" /> {t("new.up")}
</button> </button>
)} )}
</div> </div>
<div className="dir-list"> <div className="dir-list">
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>} {listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
{listing?.dirs.map((d) => ( {listing?.dirs.map((d) => (
<button key={d.path} onClick={() => setPath(d.path)}> <button key={d.path} onClick={() => setBrowse(d.path)}>
<span>📁</span> {ICONS.folder}
<span style={{ flex: 1 }}>{d.name}</span> <span style={{ flex: 1 }}>{d.name}</span>
{d.git && <span className="chip">git</span>} {d.git && <span className="chip">git</span>}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="panel">
<div className="form">
<label>{t("new.model")}</label>
<ModelSelect value={model} onChange={setModel} />
<label>{t("new.mode")}</label>
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
{agents.map((a) => (
<option key={a.name} value={a.name}>
{a.name} – {a.description}
</option>
))}
</select>
</div>
</div>
<div className="panel">
<h3>{t("new.firstPrompt")}</h3>
<textarea
style={{ width: "100%", minHeight: 110 }}
value={prompt}
placeholder={t("composer.placeholder")}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
}}
/>
</div>
{error && <div className="notice err">{error}</div>}
<button className="primary" disabled={!listing || creating} onClick={create}>
{t("new.start", { dir: rel })}
</button>
</div>
</div>
</>
); );
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { del, post } from "../api"; import { del, post } from "../api";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { useStore } from "../store"; import { useStore } from "../store";
@@ -38,7 +39,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
title={t("queue.remove")} title={t("queue.remove")}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))} onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
> >
× <Icon name="close" />
</button> </button>
)} )}
</div> </div>
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react";
import { patch, post } from "../api";
import { useFetch } from "../hooks";
import { useT } from "../i18n";
import { Icon } from "./Icon";
import { ModelSelect } from "./ModelSelect";
import { Spinner } from "./Activity";
/** Rename a session: type a name, or let a chosen model suggest one. */
export function RenameDialog({ sessionId, title, onClose }: { sessionId: string; title: string; onClose: () => void }) {
const t = useT();
const config = useFetch<{ small_model?: string; default_model?: string }>("/config").data;
const [value, setValue] = useState(title);
const [model, setModel] = useState("");
const [busy, setBusy] = useState<"gen" | "save">();
const [error, setError] = useState<string>();
const chosen = model || config?.small_model || config?.default_model || "";
const generate = async () => {
setBusy("gen");
setError(undefined);
try {
const r = await post<{ title: string }>(`/sessions/${sessionId}/title`, { model: chosen || undefined });
setValue(r.title);
} catch (e: any) {
setError(e.message);
} finally {
setBusy(undefined);
}
};
const save = async () => {
setBusy("save");
try {
await patch(`/sessions/${sessionId}`, { title: value.trim() });
onClose();
} catch (e: any) {
setError(e.message);
setBusy(undefined);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("rename.title")} onClick={(e) => e.stopPropagation()}>
<h3>{t("rename.title")}</h3>
<input
autoFocus
style={{ width: "100%" }}
value={value}
placeholder={t("session.untitled")}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !busy && void save()}
/>
<div className="rename-gen">
<span className="hint">{t("rename.generateWith")}</span>
<ModelSelect value={chosen} onChange={setModel} />
<button className="small" onClick={generate} disabled={!!busy}>
{busy === "gen" ? <Spinner small /> : <Icon name="sparkle" />} {t("rename.generate")}
</button>
</div>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" onClick={save} disabled={!!busy}>
{t("common.save")}
</button>
</div>
</div>
</div>
);
}
+70 -26
View File
@@ -1,9 +1,11 @@
import { Icon } from "./Icon";
import { useEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useState, useSyncExternalStore } from "react";
import { get, patch as apiPatch, post, put } from "../api"; import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n"; import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme"; import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs"; import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect"; import { ModelSelect } from "./ModelSelect";
@@ -54,7 +56,6 @@ function InterfaceSettings() {
const theme = useSyncExternalStore(onThemeChange, getTheme); const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode); const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!; const themeInfo = THEMES.find((th) => th.id === theme)!;
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
return ( return (
<div className="panel"> <div className="panel">
<h3>{t("settings.interface")}</h3> <h3>{t("settings.interface")}</h3>
@@ -107,18 +108,63 @@ function InterfaceSettings() {
</select> </select>
<div className="hint">{t("settings.reasoningHint")}</div> <div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label> <label>{t("settings.notifications")}</label>
<div> <PushControl />
{notif === "granted" ? ( <div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
const [state, setState] = useState<"loading" | "on" | "off" | "unsupported" | "denied">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!pushSupported()) return setState("unsupported");
if (Notification.permission === "denied") return setState("denied");
currentSubscription()
.then((s) => setState(s ? "on" : "off"))
.catch(() => setState("off"));
}, []);
const run = (f: () => Promise<unknown>) => async () => {
setBusy(true);
try {
await f();
} catch (e: any) {
toast(e.message ?? String(e));
} finally {
setBusy(false);
}
};
if (state === "loading") return <div />;
if (state === "unsupported") return <div className="hint">{t("settings.pushUnsupported")}</div>;
if (state === "denied") return <div className="hint">{t("settings.pushDenied")}</div>;
return (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{state === "on" ? (
<>
<span className="chip">{t("settings.notificationsOn")}</span> <span className="chip">{t("settings.notificationsOn")}</span>
<button
className="small"
disabled={busy}
onClick={run(async () => {
const n = await testPush();
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
})}
>
{t("settings.pushTest")}
</button>
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
{t("settings.pushDisable")}
</button>
</>
) : ( ) : (
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}> <button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
{t("settings.notificationsEnable")} {t("settings.notificationsEnable")}
</button> </button>
)} )}
</div> </div>
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
); );
} }
@@ -425,8 +471,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<th>ID</th> <th>ID</th>
<th>{t("settings.context")}</th> <th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th> <th>{t("settings.maxOutput")}</th>
<th title={t("settings.reasoning")}>🧠</th> <th title={t("settings.reasoning")}><Icon name="brain" /></th>
<th title={t("settings.vision")}>👁</th> <th title={t("settings.vision")}><Icon name="eye" /></th>
<th>{t("settings.effort")}</th> <th>{t("settings.effort")}</th>
<th>{t("settings.priceIn")}</th> <th>{t("settings.priceIn")}</th>
<th>{t("settings.priceOut")}</th> <th>{t("settings.priceOut")}</th>
@@ -472,7 +518,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
</td> </td>
<td> <td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
× <Icon name="close" />
</button> </button>
</td> </td>
</tr> </tr>
@@ -733,7 +779,7 @@ function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, str
</td> </td>
<td> <td>
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}> <button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
× <Icon name="close" />
</button> </button>
</td> </td>
</tr> </tr>
@@ -819,17 +865,17 @@ function Raw({ reload }: { reload: () => void }) {
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const; const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const; const USER_TABS = ["general", "account"] as const;
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
general: "⚙️", general: "gear",
providers: "🧠", providers: "brain",
agents: "🤖", agents: "robot",
mcp: "🔌", mcp: "plug",
permissions: "🛡️", permissions: "shield",
users: "👥", users: "users",
folders: "🗂️", folders: "folders",
tools: "🧰", tools: "toolbox",
audit: "📜", audit: "scroll",
account: "👤", account: "user",
raw: "📝", raw: "file-text",
}; };
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) { export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
@@ -843,7 +889,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
<h1>{t("nav.settings")}</h1> <h1>{t("nav.settings")}</h1>
@@ -854,9 +900,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{TABS.map((x) => ( {TABS.map((x) => (
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}> <button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
<span className="nav-icon" aria-hidden> <Icon name={TAB_ICONS[x]} className="nav-icon" />
{TAB_ICONS[x]}
</span>
{t(`settings.tab.${x}` as Key)} {t(`settings.tab.${x}` as Key)}
</button> </button>
))} ))}
+5 -4
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { get, post } from "../api"; import { get, post } from "../api";
import { toast } from "../dialogs"; import { toast } from "../dialogs";
@@ -40,7 +41,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
return ( return (
<> <>
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}> <button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })} {running ? <Spinner small /> : <Icon name="terminal" />} {t("shells.chip", { n: running, total: shells.length })}
</button> </button>
{open && ( {open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}> <div className="modal-backdrop" onClick={() => setOpen(false)}>
@@ -49,13 +50,13 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3> <h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
<span className="spacer" /> <span className="spacer" />
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}> <button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
✕ <Icon name="close" />
</button> </button>
</div> </div>
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}> <div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
{shells.map((s) => ( {shells.map((s) => (
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}> <button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
{s.running ? "● " : "○ "} <Icon name={s.running ? "dot" : "radio-off"} className={`tiny ${s.running ? "ok" : ""}`} />{" "}
{s.command.slice(0, 40)} {s.command.slice(0, 40)}
</button> </button>
))} ))}
@@ -67,7 +68,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
<span className="spacer" /> <span className="spacer" />
{current.running && ( {current.running && (
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}> <button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
■ {t("shells.stop")} <Icon name="stop" /> {t("shells.stop")}
</button> </button>
)} )}
</div> </div>
+8 -7
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, post } from "../api"; import { get, post } from "../api";
@@ -123,7 +124,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<span>{s.title || t("session.untitled")}</span> <span>{s.title || t("session.untitled")}</span>
</div> </div>
<div className="sub"> <div className="sub">
{s.shared && <span title={t("sidebar.shared")}>⇄ </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)}
</div> </div>
</button> </button>
@@ -138,25 +139,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</span> </span>
<ThemeModeButton /> <ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}> <button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
<span className="nav-icon" aria-hidden>🚪</span> <Icon name="logout" className="nav-icon" />
{t("nav.logout")} {t("nav.logout")}
</button> </button>
</div> </div>
<div className="sidebar-foot"> <div className="sidebar-foot">
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}> <button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
<span className="nav-icon" aria-hidden>📁</span> <Icon name="folder" className="nav-icon" />
{t("nav.files")} {t("nav.files")}
</button> </button>
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}> <button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
<span className="nav-icon" aria-hidden>🖼️</span> <Icon name="image" className="nav-icon" />
{t("nav.images")} {t("nav.images")}
</button> </button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}> <button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<span className="nav-icon" aria-hidden>📊</span> <Icon name="chart" className="nav-icon" />
{t("nav.stats")} {t("nav.stats")}
</button> </button>
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}> <button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
<span className="nav-icon" aria-hidden>🔧</span> <Icon name="wrench" className="nav-icon" />
{t("nav.settings")} {t("nav.settings")}
</button> </button>
</div> </div>
@@ -168,7 +169,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
function ThemeModeButton() { function ThemeModeButton() {
const t = useT(); const t = useT();
const mode = useSyncExternalStore(onThemeChange, getThemeMode); const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾"; const icon = <Icon name={mode === "auto" ? "theme-auto" : mode === "light" ? "sun" : "moon"} />;
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark"); const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
return ( return (
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}> <button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useFetch } from "../hooks"; import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
@@ -163,7 +164,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
<> <>
<div className="topbar"> <div className="topbar">
<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" />
</button> </button>
<div className="grow"> <div className="grow">
<h1>{t("nav.stats")}</h1> <h1>{t("nav.stats")}</h1>
+14 -7
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure"; import { base64Size, FileFigure } from "./FileFigure";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
@@ -26,6 +27,12 @@ export function toolTitle(name: string, input: any): string {
return input?.path ?? "."; return input?.path ?? ".";
case "bash": case "bash":
return input?.description || input?.command || ""; return input?.description || input?.command || "";
case "craft_phase":
return `${input?.phase ?? ""}${input?.note ? ` – ${input.note}` : ""}`;
case "web_search":
return input?.query ?? "";
case "diagnostics":
return input?.path ?? "";
case "glob": case "glob":
case "grep": case "grep":
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`; return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
@@ -35,7 +42,7 @@ export function toolTitle(name: string, input: any): string {
case "task": case "task":
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`; return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
case "image_compare": case "image_compare":
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`; return `${input?.original ?? ""} vs. ${input?.candidate ?? ""}`;
case "skill": case "skill":
return input?.name ?? ""; return input?.name ?? "";
case "ask_user": case "ask_user":
@@ -46,12 +53,12 @@ export function toolTitle(name: string, input: any): string {
} }
export function TodoList({ todos }: { todos: Todo[] }) { export function TodoList({ todos }: { todos: Todo[] }) {
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const; const icon = { completed: "box-on", in_progress: "play", pending: "box-off" } as const;
return ( return (
<ul> <ul>
{todos.map((td, i) => ( {todos.map((td, i) => (
<li key={i} className={td.status}> <li key={i} className={td.status}>
<span>{icon[td.status] ?? "☐"}</span> <Icon name={icon[td.status as keyof typeof icon] ?? "box-off"} />
<span>{td.content}</span> <span>{td.content}</span>
</li> </li>
))} ))}
@@ -63,7 +70,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const t = useT(); const t = useT();
const progress = useStore((s) => s.progress[call.id]); const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err"; const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–"; const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
const meta = result?.meta; const meta = result?.meta;
// The live task list has its own panel; updates are a single line here. // The live task list has its own panel; updates are a single line here.
@@ -73,8 +80,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const current = todos.find((x) => x.status === "in_progress"); const current = todos.find((x) => x.status === "in_progress");
return ( return (
<div className="todo-line"> <div className="todo-line">
☑ {t("tool.todosUpdated", { done, total: todos.length })} <Icon name="box-on" /> {t("tool.todosUpdated", { done, total: todos.length })}
{current && <span> · ▶ {current.content}</span>} {current && <span> · <Icon name="play" className="tiny" /> {current.content}</span>}
</div> </div>
); );
} }
@@ -90,7 +97,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
<div className="tool-head"> <div className="tool-head">
<span className="tool-name">{call.name}</span> <span className="tool-name">{call.name}</span>
<span className="tool-title">{call.input.path}</span> <span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span> <span className="status ok"><Icon name="check" /></span>
</div> </div>
<FileFigure url={writtenImage} name={call.input.path}> <FileFigure url={writtenImage} name={call.input.path}>
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} /> <img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
+6 -5
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFetch, useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
@@ -64,7 +65,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3> <h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
<span className="spacer" /> <span className="spacer" />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}> <button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
✕ <Icon name="close" />
</button> </button>
</div> </div>
{!data ? ( {!data ? (
@@ -85,7 +86,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<div className="kpi"> <div className="kpi">
<div className="k-label">{t("stats.tokens")}</div> <div className="k-label">{t("stats.tokens")}</div>
<div className="k-value"> <div className="k-value">
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓ {fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" /> {fmtCompact(data.total.output_tokens)}<Icon name="arrow-down" className="tiny" />
</div> </div>
<div className="k-sub"> <div className="k-sub">
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)} {t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
@@ -96,7 +97,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<div className="k-value" style={{ fontSize: 15 }}> <div className="k-value" style={{ fontSize: 15 }}>
{data.first ? fmtDate(data.first) : "–"} {data.first ? fmtDate(data.first) : "–"}
</div> </div>
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div> <div className="k-sub">{data.last ? <><Icon name="chevron-right" className="tiny" /> {fmtDate(data.last)}</> : ""}</div>
</div> </div>
</div> </div>
@@ -118,8 +119,8 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
<Row label="ID" value={<code>{m.model}</code>} /> <Row label="ID" value={<code>{m.model}</code>} />
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} /> <Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} /> <Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} /> <Row label={t("settings.reasoning")} value={mi?.reasoning ? <><Icon name="check" />{mi.effort ? ` (${mi.effort})` : ""}</> : "–"} />
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} /> <Row label={t("settings.vision")} value={mi?.vision ? <Icon name="check" /> : "–"} />
{isCurrent && mi?.context ? ( {isCurrent && mi?.context ? (
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} /> <Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
) : null} ) : null}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Icon } from "./components/Icon";
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt. // In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { t } from "./i18n"; import { t } from "./i18n";
@@ -132,7 +133,7 @@ export function DialogHost() {
<div className="toasts" role="status" aria-live="polite"> <div className="toasts" role="status" aria-live="polite">
{ts.map((x) => ( {ts.map((x) => (
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}> <div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
{x.kind === "error" ? "⚠ " : x.kind === "success" ? "✓ " : ""} {x.kind === "error" ? <Icon name="warning" /> : x.kind === "success" ? <Icon name="check" /> : null}
{x.message} {x.message}
</div> </div>
))} ))}
+75 -1
View File
@@ -210,6 +210,74 @@ export const de: Messages = {
"session.untitled": "Neue Session", "session.untitled": "Neue Session",
"wizard.title": "Was möchtest du machen?",
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
"wizard.whatToDo": "Was möchtest du machen?",
"wizard.task.craft": "Etwas craften",
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
"wizard.task.build": "Am Code arbeiten",
"wizard.task.buildDesc": "In einem Projekt umsetzen, reparieren und ausführen",
"wizard.task.plan": "Planen",
"wizard.task.planDesc": "Nur lesen: analysieren und einen Plan vorschlagen",
"wizard.whatToRecreate": "Was möchtest du nachbauen?",
"wizard.target.website": "Eine Website",
"wizard.target.app": "Eine App",
"wizard.target.game": "Ein Spiel",
"wizard.target.software": "Alte Software",
"wizard.target.fresh": "Nichts – neu anfangen",
"wizard.cleanDesc": "Nur aus dem Sichtbaren: Screenshots, sichtbarer Text, Verhalten",
"wizard.deepDesc": "Auch Quellen und Binärdateien untersuchen (Ghidra) – nachbilden, nie kopieren",
"wizard.softwareNeedsDeep": "Für die Analyse eines Programms braucht es den Deep-Modus (Dekompilieren). Im Clean Room werden nur Screenshots und deine Beschreibung genutzt.",
"wizard.sayWhat": "Sag, was du willst",
"wizard.placeholder.website": "Besondere Wünsche? z. B. nur die Startseite, in React, dunkles Theme …",
"wizard.placeholder.app": "Beschreibe die App, hänge Screenshots an …",
"wizard.placeholder.game": "Beschreibe das Spiel, hänge Screenshots an …",
"wizard.placeholder.software": "Was ist das für ein Programm? Worauf soll die neue Version laufen? Programm, Archiv oder Installer anhängen.",
"wizard.placeholder.fresh": "Zum Beispiel: eine Website für meine Bäckerei",
"wizard.placeholder.task": "Was soll gemacht werden?",
"wizard.enterHint": "Enter zum Starten · Shift+Enter für neue Zeile · Dateien ablegen oder einfügen",
"wizard.softwareHint": "Programm mit Bibliotheken, zip/tar.gz oder Installer anhängen – landet in original/",
"wizard.attach": "Dateien anhängen",
"wizard.ideas": "Ideen:",
"wizard.idea.siteLanding": "Nur die Startseite, als reines HTML/CSS",
"wizard.idea.siteShop": "Alle Seiten, in React mit Tailwind",
"wizard.idea.appTodo": "Eine To-do-App wie auf meinen Screenshots",
"wizard.idea.appNotes": "Eine Notiz-App mit dem gleichen Layout",
"wizard.idea.gameSnake": "Ein einfaches Snake-Spiel im Browser",
"wizard.idea.gameBreakout": "Ein Breakout-Klon mit eigener Grafik",
"wizard.idea.swDos": "Ein altes DOS-Spiel – die neue Version soll im Browser laufen",
"wizard.idea.swTool": "Ein altes Windows-Tool – als plattformübergreifende App nachbauen",
"wizard.idea.freshBakery": "Eine Website für meine Bäckerei",
"wizard.idea.freshSnake": "Ein einfaches Snake-Spiel",
"wizard.idea.planFeature": "Ein neues Feature für dieses Projekt planen",
"wizard.idea.planRefactor": "Die größten Schwächen finden und ein Refactoring vorschlagen",
"wizard.idea.buildTests": "Tests für das Kernmodul schreiben und Fehler beheben",
"wizard.idea.buildBug": "Finde und behebe den Fehler: ",
"wizard.builtBy": "Gebaut von",
"wizard.newFolderIn": "Gespeichert in einem neuen Ordner in",
"wizard.existingFolder": "Arbeitet in",
"wizard.chooseFolder": "Ordner wählen…",
"wizard.useNewFolder": "Neuen Ordner verwenden",
"wizard.start": "Start",
"wizard.creatingFolder": "Ordner wird angelegt…",
"wizard.uploading": "Dateien werden hochgeladen…",
"wizard.starting": "Wird gestartet…",
"phase.study": "Studieren",
"phase.specify": "Spezifizieren",
"phase.build": "Bauen",
"phase.check": "Prüfen",
"phase.ready": "Fertig",
"wizard.cloning": "Repository wird geklont…",
"wizard.originalTitle": "Original-Software hochladen",
"wizard.originalHint": "Programmordner als zip, tar.gz, 7z, Installer oder einzelne Dateien – bis 500 MB pro Datei. Klicken oder hier ablegen.",
"wizard.whereLabel": "Wo soll gebaut werden?",
"wizard.whereNew": "Neuer Ordner",
"wizard.whereExisting": "Bestehender Ordner",
"wizard.whereGit": "Git-Repository",
"wizard.cloneInto": "Geklont nach",
"rename.title": "Session umbenennen",
"rename.generateWith": "Namen vorschlagen mit",
"rename.generate": "Generieren",
"new.title": "Neue Session", "new.title": "Neue Session",
"new.directory": "Projektverzeichnis", "new.directory": "Projektverzeichnis",
"new.up": "Hoch", "new.up": "Hoch",
@@ -317,7 +385,13 @@ export const de: Messages = {
"settings.notifications": "Benachrichtigungen", "settings.notifications": "Benachrichtigungen",
"settings.notificationsEnable": "Aktivieren", "settings.notificationsEnable": "Aktivieren",
"settings.notificationsOn": "Aktiv", "settings.notificationsOn": "Aktiv",
"settings.notificationsHint": "Benachrichtigen, wenn ein Agent eine Frage stellt, während die App im Hintergrund ist.", "settings.pushTest": "Test senden",
"settings.pushTestSent": "Testbenachrichtigung gesendet",
"settings.pushTestNone": "Kein Gerät angemeldet",
"settings.pushDisable": "Ausschalten",
"settings.pushUnsupported": "Dieser Browser unterstützt keine Push-Benachrichtigungen. Auf iPhone/iPad: CodeCraft zuerst zum Home-Bildschirm hinzufügen.",
"settings.pushDenied": "Benachrichtigungen sind für diese Seite blockiert – in den Browser-Einstellungen erlauben.",
"settings.notificationsHint": "Push-Benachrichtigungen auf diesem Gerät, wenn ein Agent eine Freigabe braucht, eine Frage stellt oder eine längere Aufgabe beendet – nur wenn CodeCraft nicht geöffnet ist.",
"settings.server": "Server", "settings.server": "Server",
"settings.workspace": "Workspace", "settings.workspace": "Workspace",
"settings.defaultModel": "Standardmodell", "settings.defaultModel": "Standardmodell",
+75 -1
View File
@@ -209,6 +209,74 @@ export const en = {
"session.untitled": "New session", "session.untitled": "New session",
"wizard.title": "What do you want to do?",
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
"wizard.whatToDo": "What do you want to do?",
"wizard.task.craft": "Craft something",
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
"wizard.task.build": "Work on code",
"wizard.task.buildDesc": "Implement, fix and run things in a project",
"wizard.task.plan": "Plan",
"wizard.task.planDesc": "Read-only: analyse and propose a plan",
"wizard.whatToRecreate": "What do you want to recreate?",
"wizard.target.website": "A website",
"wizard.target.app": "An app",
"wizard.target.game": "A game",
"wizard.target.software": "Old software",
"wizard.target.fresh": "Nothing – start fresh",
"wizard.cleanDesc": "Only from what you can see: screenshots, visible text, behaviour",
"wizard.deepDesc": "Also study sources and binaries (Ghidra) – re-create, never copy",
"wizard.softwareNeedsDeep": "Analysing a program needs Deep mode (decompiling). In clean room mode only screenshots and your description are used.",
"wizard.sayWhat": "Say what you want",
"wizard.placeholder.website": "Anything special? e.g. only the start page, in React, dark theme …",
"wizard.placeholder.app": "Describe the app, attach screenshots …",
"wizard.placeholder.game": "Describe the game, attach screenshots or a video description …",
"wizard.placeholder.software": "What is the program? What should the new version run on? Attach the program, an archive or installer.",
"wizard.placeholder.fresh": "For example: a website for my bakery",
"wizard.placeholder.task": "What should be done?",
"wizard.enterHint": "Enter to start · Shift+Enter for a new line · drop or paste files",
"wizard.softwareHint": "Attach the program with its libraries, a zip/tar.gz or an installer – it goes to original/",
"wizard.attach": "Attach files",
"wizard.ideas": "Ideas:",
"wizard.idea.siteLanding": "Only the start page, as plain HTML/CSS",
"wizard.idea.siteShop": "All pages, in React with Tailwind",
"wizard.idea.appTodo": "A to-do app like in my screenshots",
"wizard.idea.appNotes": "A notes app with the same layout",
"wizard.idea.gameSnake": "A simple snake game in the browser",
"wizard.idea.gameBreakout": "A breakout clone with original graphics",
"wizard.idea.swDos": "An old DOS game – the new version should run in the browser",
"wizard.idea.swTool": "An old Windows tool – rebuild it as a cross-platform app",
"wizard.idea.freshBakery": "A website for my bakery",
"wizard.idea.freshSnake": "A simple snake game",
"wizard.idea.planFeature": "Plan a new feature for this project",
"wizard.idea.planRefactor": "Find the biggest weaknesses and propose a refactoring",
"wizard.idea.buildTests": "Add tests for the core module and fix what fails",
"wizard.idea.buildBug": "Find and fix the bug: ",
"wizard.builtBy": "Built by",
"wizard.newFolderIn": "Saved in a new folder in",
"wizard.existingFolder": "Works in",
"wizard.chooseFolder": "Choose a folder…",
"wizard.useNewFolder": "Use a new folder",
"wizard.start": "Start",
"wizard.creatingFolder": "Creating folder…",
"wizard.uploading": "Uploading files…",
"wizard.starting": "Starting…",
"phase.study": "Study",
"phase.specify": "Specify",
"phase.build": "Build",
"phase.check": "Check",
"phase.ready": "Ready",
"wizard.cloning": "Cloning repository…",
"wizard.originalTitle": "Upload the original software",
"wizard.originalHint": "Program folder as zip, tar.gz, 7z, an installer or single files – up to 500 MB each. Click or drop here.",
"wizard.whereLabel": "Where should it be built?",
"wizard.whereNew": "New folder",
"wizard.whereExisting": "Existing folder",
"wizard.whereGit": "Git repository",
"wizard.cloneInto": "Cloned into",
"rename.title": "Rename session",
"rename.generateWith": "Suggest a name with",
"rename.generate": "Generate",
"new.title": "New session", "new.title": "New session",
"new.directory": "Project directory", "new.directory": "Project directory",
"new.up": "Up", "new.up": "Up",
@@ -316,7 +384,13 @@ export const en = {
"settings.notifications": "Notifications", "settings.notifications": "Notifications",
"settings.notificationsEnable": "Enable", "settings.notificationsEnable": "Enable",
"settings.notificationsOn": "Enabled", "settings.notificationsOn": "Enabled",
"settings.notificationsHint": "Notify when an agent asks a question while the app is in the background.", "settings.pushTest": "Send test",
"settings.pushTestSent": "Test notification sent",
"settings.pushTestNone": "No device subscribed",
"settings.pushDisable": "Turn off",
"settings.pushUnsupported": "This browser does not support push notifications. On iPhone/iPad: add CodeCraft to the home screen first.",
"settings.pushDenied": "Notifications are blocked for this site – allow them in the browser settings.",
"settings.notificationsHint": "Push notifications on this device when an agent asks for permission, asks a question or finishes a longer task – only while CodeCraft is not open.",
"settings.server": "Server", "settings.server": "Server",
"settings.workspace": "Workspace", "settings.workspace": "Workspace",
"settings.defaultModel": "Default model", "settings.defaultModel": "Default model",
+53
View File
@@ -0,0 +1,53 @@
import { get, post } from "./api";
function b64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const s = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((b64.length + 3) % 4));
const out = new Uint8Array(new ArrayBuffer(s.length));
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
export const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
/** Current push subscription of this device (null if none). */
export async function currentSubscription(): Promise<PushSubscription | null> {
if (!pushSupported()) return null;
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
/** Ask for permission, subscribe this device and register it with the server. */
export async function enablePush(): Promise<void> {
if (!pushSupported()) throw new Error("Push is not supported by this browser (on iOS: install the app to the home screen first).");
const perm = await Notification.requestPermission();
if (perm !== "granted") throw new Error("Notifications were not allowed.");
const reg = await navigator.serviceWorker.ready;
const { key } = await get<{ key: string }>("/push/key");
let sub = await reg.pushManager.getSubscription();
// A subscription for another server key must be replaced.
if (sub && sub.options.applicationServerKey) {
const cur = new Uint8Array(sub.options.applicationServerKey);
const want = b64ToBytes(key);
if (cur.length !== want.length || cur.some((b, i) => b !== want[i])) {
await sub.unsubscribe();
sub = null;
}
}
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(key) });
await post("/push/subscribe", sub.toJSON());
localStorage.setItem("codecraft.push", "1");
}
export async function disablePush(): Promise<void> {
const sub = await currentSubscription();
if (sub) {
await fetch("/api/push/subscribe", { method: "DELETE", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: sub.endpoint }) });
await sub.unsubscribe();
}
localStorage.removeItem("codecraft.push");
}
export async function testPush(): Promise<number> {
const r = await post<{ sent: number }>("/push/test", {});
return r.sent;
}
+2
View File
@@ -197,6 +197,8 @@ export function applyEvent(ev: ServerEvent) {
/** Notify when the app is in the background and an agent needs input. */ /** Notify when the app is in the background and an agent needs input. */
function notify(body: string) { function notify(body: string) {
if (document.visibilityState === "visible" || !("Notification" in window)) return; if (document.visibilityState === "visible" || !("Notification" in window)) return;
// With Web Push on this device the server notifies (avoid duplicates).
if (localStorage.getItem("codecraft.push") === "1") return;
if (Notification.permission === "granted") { if (Notification.permission === "granted") {
new Notification("CodeCraft", { body, icon: "/icon-192.png" }); new Notification("CodeCraft", { body, icon: "/icon-192.png" });
} }
+477
View File
@@ -2317,3 +2317,480 @@ button.danger-solid {
.menu-pop-body a:hover { .menu-pop-body a:hover {
background: var(--bg2); background: var(--bg2);
} }
/* --- new session wizard ------------------------------------------------------------ */
.wizard {
max-width: 980px;
}
.wiz-title {
font-size: 30px;
margin: 8px 0 4px;
color: var(--fg0);
}
.wiz-sub {
color: var(--fg3);
margin: 0 0 18px;
}
.wiz-panel {
padding: 22px 24px;
}
.wiz-step {
margin-bottom: 26px;
}
.wiz-step h3 {
display: flex;
align-items: center;
gap: 12px;
font-size: 19px;
margin: 0 0 14px;
color: var(--fg0);
}
.wiz-num {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--bg3);
background: var(--bg2);
font-size: 14px;
font-weight: 600;
color: var(--fg1);
flex-shrink: 0;
}
.wiz-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.wiz-cards.five {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.wiz-cards.two {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.wiz-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
padding: 14px 16px;
min-height: 64px;
border-radius: 10px;
border: 1px solid var(--bg3);
background: var(--bg0);
color: var(--fg1);
font-size: 15px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wiz-card:hover {
border-color: var(--fg3);
}
.wiz-card.active {
border: 2px solid var(--accent);
padding: 13px 15px;
background: color-mix(in srgb, var(--accent) 12%, var(--bg0));
}
.wiz-card-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-right: 18px;
}
.wiz-card-title {
font-weight: 600;
color: var(--fg0);
}
.wiz-card-desc {
font-size: 12.5px;
color: var(--fg3);
line-height: 1.35;
}
.wiz-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.wiz-card .wiz-icon {
color: var(--fg2);
}
.wiz-card.active .wiz-icon {
color: var(--accent);
}
.wiz-check {
position: absolute;
top: 10px;
right: 10px;
width: 18px;
height: 18px;
fill: none;
stroke: var(--accent);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.wiz-url {
width: 100%;
margin-bottom: 10px;
font-size: 15px;
}
.wiz-input {
border: 1px solid var(--bg3);
border-radius: 12px;
background: var(--bg0);
padding: 12px 14px 8px;
}
.wiz-input:focus-within {
border-color: var(--accent);
}
.wiz-input textarea {
width: 100%;
min-height: 96px;
border: none;
background: transparent;
resize: vertical;
font-size: 15px;
outline: none;
padding: 0;
}
.wiz-input-foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--fg3);
}
.wiz-input-foot .wiz-icon {
width: 20px;
height: 20px;
}
.wiz-ideas {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
color: var(--fg2);
}
.chip-btn {
border: 1px solid var(--bg3);
border-radius: 8px;
background: var(--bg0);
color: var(--fg1);
padding: 6px 12px;
cursor: pointer;
}
.chip-btn:hover {
border-color: var(--accent);
}
.wiz-foot {
display: flex;
flex-direction: column;
gap: 14px;
border-top: 1px solid var(--bg2);
padding-top: 18px;
}
.wiz-foot-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.wiz-foot-row label {
color: var(--fg2);
}
.wiz-folder {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: var(--fg2);
}
.wiz-folder .wiz-icon {
width: 20px;
height: 20px;
}
.wiz-folder-text {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.wiz-folder-name {
width: 220px;
font-family: var(--mono);
}
.link-btn {
background: none;
border: none;
color: var(--blue);
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 3px;
}
.wiz-picker .dir-list button .wiz-icon {
width: 18px;
height: 18px;
color: var(--fg3);
}
.wiz-start-row {
display: flex;
justify-content: flex-end;
}
.wiz-start {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
padding: 12px 26px;
border-radius: 10px;
}
.wiz-start .wiz-icon {
width: 20px;
height: 20px;
}
@media (max-width: 700px) {
.wiz-panel {
padding: 16px;
}
.wiz-title {
font-size: 24px;
}
.wiz-cards,
.wiz-cards.five,
.wiz-cards.two {
grid-template-columns: 1fr 1fr;
}
.wiz-card {
padding: 10px 12px;
}
.wiz-card-desc {
display: none;
}
}
/* --- craft phase bar ----------------------------------------------------------------- */
.phase-bar {
display: flex;
align-items: center;
gap: 0;
padding: 8px 16px;
border-bottom: 1px solid var(--bg2);
overflow-x: auto;
font-size: 12.5px;
}
.phase-bar .phase {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--fg3);
white-space: nowrap;
}
.phase-bar .phase .dot {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid var(--bg3);
display: inline-grid;
place-items: center;
font-size: 10px;
}
.phase-bar .phase.done {
color: var(--fg2);
}
.phase-bar .phase.done .dot {
background: var(--green);
border-color: var(--green);
}
.phase-bar .phase.done .dot svg {
width: 11px;
height: 11px;
fill: none;
stroke: var(--bg0);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.phase-bar .phase.current {
color: var(--accent);
font-weight: 600;
}
.phase-bar .phase.current .dot {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
animation: glow 1.6s ease-in-out infinite;
}
.phase-bar .sep {
flex: 1;
min-width: 18px;
max-width: 60px;
height: 1.5px;
background: var(--bg3);
margin: 0 8px;
}
.phase-bar .sep.done {
background: var(--green);
}
.wiz-drop {
display: flex;
align-items: center;
gap: 14px;
margin-top: 14px;
padding: 18px;
border: 2px dashed var(--bg3);
border-radius: 12px;
cursor: pointer;
color: var(--fg1);
}
.wiz-drop:hover {
border-color: var(--accent);
}
.wiz-drop .wiz-icon {
width: 32px;
height: 32px;
color: var(--accent);
}
.wiz-originals {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.wiz-original {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 8px;
background: var(--bg1);
font-size: 13px;
}
.wiz-original .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiz-original .size {
color: var(--muted);
}
.wiz-original .bar {
width: 120px;
height: 6px;
border-radius: 3px;
background: var(--bg3);
overflow: hidden;
}
.wiz-original .bar i {
display: block;
height: 100%;
background: var(--accent);
transition: width 0.2s;
}
.wiz-folder-modes {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wiz-folder-mode {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--bg3);
background: var(--bg0);
color: var(--fg1);
cursor: pointer;
}
.wiz-folder-mode .wiz-icon {
width: 18px;
height: 18px;
}
.wiz-folder-mode.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--bg0));
}
/* --- icons (SVG, coloured by the theme via currentColor) --------------------- */
.icon {
width: 1.15em;
height: 1.15em;
vertical-align: -0.2em;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.icon .fill {
fill: currentColor;
stroke: none;
}
.icon.tiny {
width: 0.85em;
height: 0.85em;
vertical-align: -0.08em;
}
.icon.ok {
color: var(--green);
}
.icon.ficon {
color: var(--fg3);
}
.icon.ficon.dir {
color: var(--accent);
}
.icon.nav-icon {
width: 1.15em;
margin-right: 8px;
}
.file-chip .icon.file-chip-icon {
color: var(--fg3);
}
.theme-toggle .icon {
width: 17px;
height: 17px;
}
.title-edit {
margin-left: 6px;
opacity: 0;
transition: opacity 0.15s;
vertical-align: middle;
padding: 2px 6px;
}
.topbar h1:hover .title-edit,
.title-edit:focus-visible {
opacity: 0.8;
}
@media (pointer: coarse) {
.title-edit {
opacity: 0.6;
}
}
.rename-gen {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.rename-gen button {
display: inline-flex;
align-items: center;
gap: 6px;
}
+2
View File
@@ -58,6 +58,8 @@ export interface Session {
owner_id?: string | null; owner_id?: string | null;
/** Craft: clean room (true) or deep (false); undefined = server default. */ /** Craft: clean room (true) or deep (false); undefined = server default. */
clean_room?: boolean | null; clean_room?: boolean | null;
/** Craft workflow phase reported by the agent. */
phase?: string;
/** Listed because it was shared with the current user. */ /** Listed because it was shared with the current user. */
shared?: boolean; shared?: boolean;
} }
+2
View File
@@ -31,6 +31,8 @@ export default defineConfig({
runtimeCaching: [], runtimeCaching: [],
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed. // The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
globIgnores: ["**/occt-import-js*"], globIgnores: ["**/occt-import-js*"],
// Web Push handlers (push, notificationclick).
importScripts: ["push-sw.js"],
}, },
}), }),
], ],