Merge remote-tracking branch 'origin/main' into multi-tab-ai-session-sync

# Conflicts:
#	frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
This commit is contained in:
hugocasa
2026-08-31 20:44:02 +02:00
72 changed files with 2752 additions and 1238 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.799.0"
".": "1.800.0"
}
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [1.800.0](https://github.com/windmill-labs/windmill/compare/v1.799.0...v1.800.0) (2026-08-31)
### Features
* add --keep-deleted flag to wmill sync pull and push ([#10878](https://github.com/windmill-labs/windmill/issues/10878)) ([66123f3](https://github.com/windmill-labs/windmill/commit/66123f3a9b8978c0084b02f50b44cffba125a13a))
* day-partition the service log index and expire whole chunks ([#10893](https://github.com/windmill-labs/windmill/issues/10893)) ([d91ee46](https://github.com/windmill-labs/windmill/commit/d91ee4614a70f20a194f47e190327129f499ec63))
* free AI tokens + home search/filter revamp ([#10020](https://github.com/windmill-labs/windmill/issues/10020)) ([716ce2e](https://github.com/windmill-labs/windmill/commit/716ce2ece00cd5cfb8641afc6432636bc4aa46e9))
* make the service log retention period an instance setting ([#10889](https://github.com/windmill-labs/windmill/issues/10889)) ([815de49](https://github.com/windmill-labs/windmill/commit/815de49e2322f85ca92b1e41a2bcd22591ebe93f))
* rework the evals dataset drawer and run navigation ([#10884](https://github.com/windmill-labs/windmill/issues/10884)) ([1462f17](https://github.com/windmill-labs/windmill/commit/1462f17643302127b4bd76bacfde80cc03f9d606))
* serve service log context from parquet and retire the raw log files ([#10892](https://github.com/windmill-labs/windmill/issues/10892)) ([338d75c](https://github.com/windmill-labs/windmill/commit/338d75cc5227e352cb84828c99bfd3b984cf0fa5))
* serve service log retrieval from a columnar parquet store ([#10886](https://github.com/windmill-labs/windmill/issues/10886)) ([7c1a785](https://github.com/windmill-labs/windmill/commit/7c1a785f756ed27e4425f6534709b19971a73a97))
### Bug Fixes
* correct the service log ingest flush boundary ([#10898](https://github.com/windmill-labs/windmill/issues/10898)) ([ac56586](https://github.com/windmill-labs/windmill/commit/ac56586c0e56d4022761d3c80306a03d57f8bfcb))
* harden the service log indexer's recovery and read paths ([#10904](https://github.com/windmill-labs/windmill/issues/10904)) ([831370c](https://github.com/windmill-labs/windmill/commit/831370cdde8e06f4298b17baa1a0041bacdd98c7))
* keep raw-app editor selection consistent across sidebar and tabs ([#10885](https://github.com/windmill-labs/windmill/issues/10885)) ([b57e231](https://github.com/windmill-labs/windmill/commit/b57e231c2bf5e5fe007f0aa7b958a51e32b47141))
* register every rotated service log file exactly once ([#10891](https://github.com/windmill-labs/windmill/issues/10891)) ([c817248](https://github.com/windmill-labs/windmill/commit/c8172480b0b1be6c57210212afc71d6ec8711235))
* show a loading indicator while the initial data table migration is generated ([#10900](https://github.com/windmill-labs/windmill/issues/10900)) ([b998267](https://github.com/windmill-labs/windmill/commit/b998267c91b9dcf02787768f6205cc5aeda494fb))
* track outstanding service log files on the rows themselves ([#10894](https://github.com/windmill-labs/windmill/issues/10894)) ([aa4a6ff](https://github.com/windmill-labs/windmill/commit/aa4a6ffd66813010a79c07741b01a984ed4e7df6))
### Performance Improvements
* add service log documents to the index one batch at a time ([#10906](https://github.com/windmill-labs/windmill/issues/10906)) ([0c2eb0a](https://github.com/windmill-labs/windmill/commit/0c2eb0ae3d18f49c21370131d15011e8dd103746))
## [1.799.0](https://github.com/windmill-labs/windmill/compare/v1.798.1...v1.799.0) (2026-08-28)
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, $1::bigint, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + $1::bigint,\n updated_at = now()\n RETURNING cost_nanos",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Date"
]
},
"nullable": [
false
]
},
"hash": "44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, GREATEST(0, $1::bigint), now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_daily_usage.cost_nanos + $1::bigint),\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Date"
]
},
"nullable": []
},
"hash": "acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, $2::bigint, now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + $2::bigint,\n updated_at = now()\n RETURNING cost_nanos",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cost_nanos",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, GREATEST(0, $2::bigint), now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_usage.cost_nanos + $2::bigint),\n updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f"
}
+125 -125
View File
@@ -430,7 +430,7 @@ dependencies = [
"arrow-schema",
"chrono",
"half",
"indexmap 2.14.0",
"indexmap 2.14.1",
"lexical-core",
"memchr",
"num",
@@ -3247,7 +3247,7 @@ dependencies = [
"base64 0.22.1",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"libc",
"log",
"object_store",
@@ -3426,7 +3426,7 @@ dependencies = [
"datafusion-functions-aggregate-common",
"datafusion-functions-window-common",
"datafusion-physical-expr-common",
"indexmap 2.14.0",
"indexmap 2.14.1",
"paste",
"recursive",
"serde_json",
@@ -3441,7 +3441,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839"
dependencies = [
"arrow",
"datafusion-common",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"paste",
]
@@ -3595,7 +3595,7 @@ dependencies = [
"datafusion-common",
"datafusion-expr",
"datafusion-physical-expr",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"recursive",
@@ -3618,7 +3618,7 @@ dependencies = [
"datafusion-physical-expr-common",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"paste",
@@ -3680,7 +3680,7 @@ dependencies = [
"futures",
"half",
"hashbrown 0.14.5",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"log",
"parking_lot",
@@ -3722,7 +3722,7 @@ dependencies = [
"bigdecimal",
"datafusion-common",
"datafusion-expr",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"recursive",
"regex",
@@ -3853,7 +3853,7 @@ dependencies = [
"deno_path_util",
"deno_unsync",
"futures",
"indexmap 2.14.0",
"indexmap 2.14.1",
"libc",
"parking_lot",
"percent-encoding",
@@ -4122,7 +4122,7 @@ version = "0.228.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro-rules",
"proc-macro2",
"quote",
@@ -5177,9 +5177,9 @@ dependencies = [
[[package]]
name = "frostem"
version = "1.20260821.2"
version = "1.20260821.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ed6437a2ed7fc408115e25cdb41e15ba4b17742a4c8d3d1b5276fe9b687209"
checksum = "ca2e81eab151ba68484704bb3d21b4b4d2747314d3081fa86fa6c7300a4c42e2"
[[package]]
name = "fs3"
@@ -5755,9 +5755,9 @@ dependencies = [
[[package]]
name = "gosyn"
version = "0.2.14"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9758a950dc61a15bc65162f72f5bec7e8efde91f102dc7dce7317f67019a6ba9"
checksum = "28f4ed3cbcb66ddad22553bb3b94087573cf492a34501bc025a23167ed57e856"
dependencies = [
"anyhow",
"strum",
@@ -5797,7 +5797,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.14.0",
"indexmap 2.14.1",
"slab",
"tokio",
"tokio-util",
@@ -5816,7 +5816,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.5.0",
"indexmap 2.14.0",
"indexmap 2.14.1",
"slab",
"tokio",
"tokio-util",
@@ -5898,7 +5898,7 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -5951,9 +5951,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hex"
@@ -6570,9 +6570,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.14.0"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
@@ -6958,7 +6958,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
]
[[package]]
@@ -8124,7 +8124,7 @@ dependencies = [
"dirs-sys 0.4.1",
"fancy-regex 0.14.0",
"heck",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"lru 0.12.5",
"miette",
@@ -8302,7 +8302,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi 0.5.3",
"libc",
]
@@ -9123,7 +9123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
"indexmap 2.14.1",
]
[[package]]
@@ -9389,7 +9389,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"hermit-abi 0.5.3",
"pin-project-lite",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -9619,7 +9619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1"
dependencies = [
"futures",
"indexmap 2.14.0",
"indexmap 2.14.1",
"nix 0.30.1",
"tokio",
"tracing",
@@ -10565,7 +10565,7 @@ dependencies = [
"convert_case 0.10.0",
"fnv",
"ident_case",
"indexmap 2.14.0",
"indexmap 2.14.1",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -11398,7 +11398,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"memchr",
"serde",
@@ -11492,7 +11492,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"indexmap 2.14.1",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
@@ -11520,7 +11520,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"ryu",
"serde",
@@ -11533,7 +11533,7 @@ version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"itoa",
"libyml",
"memchr",
@@ -11953,7 +11953,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap 2.14.0",
"indexmap 2.14.1",
"log",
"memchr",
"once_cell",
@@ -12310,7 +12310,7 @@ checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97"
dependencies = [
"anyhow",
"bytes-str",
"indexmap 2.14.0",
"indexmap 2.14.1",
"serde",
"serde_json",
"swc_config_macro",
@@ -12444,7 +12444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c6f1b8f4232e7a7f614ff7c0f6ccb89c2d028cdf7629f79ad710cff5b28b62c"
dependencies = [
"better_scoped_tls",
"indexmap 2.14.0",
"indexmap 2.14.1",
"once_cell",
"par-core",
"phf 0.11.3",
@@ -12510,7 +12510,7 @@ checksum = "69ea0052ac23b5b9fbc85bbdb1791b36b918f9d55f594b0ed8e25babb4c32d16"
dependencies = [
"base64 0.22.1",
"bytes-str",
"indexmap 2.14.0",
"indexmap 2.14.1",
"once_cell",
"rustc-hash 2.1.3",
"serde",
@@ -12550,7 +12550,7 @@ version = "21.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83259addd99ed4022aa9fc4d39428c008d3d42533769e1a005529da18cde4568"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"num_cpus",
"once_cell",
"par-core",
@@ -13549,7 +13549,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
@@ -13562,7 +13562,7 @@ version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap 2.14.0",
"indexmap 2.14.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
@@ -13670,7 +13670,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.14.0",
"indexmap 2.14.1",
"pin-project-lite",
"slab",
"sync_wrapper",
@@ -13885,9 +13885,9 @@ dependencies = [
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
[[package]]
name = "tree-sitter-r"
@@ -14746,7 +14746,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14877,7 +14877,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"argon2",
@@ -14908,7 +14908,7 @@ dependencies = [
"hmac",
"http 1.5.0",
"hyper 1.11.1",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
"lazy_static",
@@ -15017,7 +15017,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15040,7 +15040,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15057,7 +15057,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15083,7 +15083,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15093,7 +15093,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15110,7 +15110,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15132,7 +15132,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15155,7 +15155,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15171,7 +15171,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15193,7 +15193,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15214,7 +15214,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15228,7 +15228,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15263,7 +15263,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15288,7 +15288,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15316,12 +15316,12 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
"http 1.5.0",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"lazy_static",
"serde",
@@ -15338,7 +15338,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15358,7 +15358,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15396,7 +15396,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15424,7 +15424,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"lazy_static",
"serde",
@@ -15436,7 +15436,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15460,7 +15460,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15474,7 +15474,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15509,7 +15509,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15523,7 +15523,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15542,7 +15542,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15580,7 +15580,7 @@ dependencies = [
"hex",
"hmac",
"hyper 1.11.1",
"indexmap 2.14.0",
"indexmap 2.14.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
"lazy_static",
@@ -15646,7 +15646,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -15665,7 +15665,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"regex",
"serde",
@@ -15680,7 +15680,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15707,7 +15707,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"futures",
@@ -15724,7 +15724,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15740,7 +15740,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15761,7 +15761,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15792,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15817,7 +15817,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15851,7 +15851,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"futures",
@@ -15869,7 +15869,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15878,7 +15878,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15890,7 +15890,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15902,7 +15902,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15914,7 +15914,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15926,7 +15926,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15938,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -15949,7 +15949,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15960,7 +15960,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15972,7 +15972,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15983,7 +15983,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16005,7 +16005,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16017,7 +16017,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16031,7 +16031,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16048,7 +16048,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16061,7 +16061,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde",
@@ -16073,7 +16073,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16107,7 +16107,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16123,7 +16123,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16137,7 +16137,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16176,7 +16176,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"const_format",
@@ -16216,7 +16216,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16227,7 +16227,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16262,7 +16262,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16286,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16319,7 +16319,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16399,7 +16399,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16433,7 +16433,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16469,7 +16469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16492,7 +16492,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16516,7 +16516,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16540,7 +16540,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16575,7 +16575,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16603,7 +16603,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16628,7 +16628,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"bitflags 2.13.1",
@@ -16647,7 +16647,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16764,7 +16764,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"bytes",
"futures",
@@ -17563,7 +17563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
dependencies = [
"crc32fast",
"indexmap 2.14.0",
"indexmap 2.14.1",
"memchr",
"typed-path",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.799.0"
version = "1.800.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.799.0"
version = "1.800.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
df60763d1f243b0048dfc3fe700bc026b257bea8
f2a31156ac08ecb02d89dbc66d72be58e9c877ff
@@ -0,0 +1,2 @@
DROP TABLE ai_free_token_daily_usage;
DROP TABLE ai_free_token_usage;
@@ -0,0 +1,19 @@
-- One-time grant of the Windmill-provided free AI tier, measured as cost in nano-dollars
-- (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction of a fresh input
-- token, so a token count wildly overstates the real bill. The grant never resets: once
-- spent, the user must bring their own API key. Keyed by normalized email so the allowance
-- is shared across a user's workspaces (and is resistant to +tag / gmail-dot aliasing).
CREATE TABLE ai_free_token_usage (
email VARCHAR(255) PRIMARY KEY,
cost_nanos BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Instance-wide daily cost ceiling (nano-dollars) for the free tier — a kill-switch
-- independent of the per-user grant, bounding the blast radius of a bad day. One row per
-- UTC day.
CREATE TABLE ai_free_token_daily_usage (
day DATE PRIMARY KEY,
cost_nanos BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.799.0"
version = "1.800.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.799.0"
version = "1.800.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2
View File
@@ -38,6 +38,8 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien
FK: (workspace_id) -> workspace(id)
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
FK: (workspace_id) -> workspace(id)
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
+19 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.799.0
version: 1.800.0
title: Windmill API
contact:
@@ -27256,11 +27256,29 @@ components:
type: integer
minimum: 1
maximum: 2000000
free_tier:
$ref: "#/components/schemas/FreeTierInfo"
model_pricing:
type: object
additionalProperties:
$ref: "#/components/schemas/ModelPriceOverride"
FreeTierInfo:
type: object
description: >-
Read-only. Present when the workspace has no AI provider of its own and is running
on Windmill's free tier. Ignored on write.
properties:
exhausted:
type: boolean
description: The one-time grant is spent; no provider is served and the user must add their own API key.
used_ratio:
type: number
description: Fraction of the grant consumed, 0 to 1.
required:
- exhausted
- used_ratio
ModelPriceOverride:
type: object
description: negotiated rates in USD per million tokens, keyed `provider:model`
+182 -97
View File
@@ -409,6 +409,19 @@ impl ExpiringProviderCredentials {
}
}
/// Set on the copilot config when the workspace has no AI provider of its own and is
/// running on Windmill's free tier, so the client can label the lent model as free, warn
/// before the grant runs out, and tell the user to add their own key once it has — rather
/// than showing the same "no provider configured" state a never-configured workspace gets.
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct FreeTierInfo {
/// The grant is spent: no provider is served and the user must bring their own key.
pub exhausted: bool,
/// Fraction of the grant consumed, 0.0..=1.0. A ratio, not a dollar amount — the
/// pricing model stays server-side.
pub used_ratio: f64,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -423,6 +436,11 @@ pub struct AIConfig {
pub custom_prompts: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_per_model: Option<HashMap<String, i32>>,
/// Response-only: this same struct is the request body for saving a workspace's AI
/// config, and `skip_deserializing` is what stops a client from storing a forged
/// free-tier marker. Only the server sets it, per-request.
#[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
pub free_tier: Option<FreeTierInfo>,
/// Per-model price overrides, keyed `provider:model` like `max_tokens_per_model`.
/// Only models whose rates differ from the built-in table are stored.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1013,83 +1031,119 @@ async fn proxy(
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
}
let mut credentials = match workspace_cache {
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
request_cache.credentials
}
_ => {
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
// Set when serving the request through Windmill's free AI tier (the lent key). Holds
// the per-user concurrency lock and drives response metering.
let mut free_lease: Option<crate::ai_free_tier_oss::FreeTierLease> = None;
let mut credentials = 'cred: {
match workspace_cache {
Some(request_cache)
if !request_cache.is_expired() && forced_resource_path.is_none() =>
{
request_cache.credentials
}
_ => {
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
let instance_has_config =
instance_config.as_ref().is_some_and(|v| {
serde_json::from_value::<AIConfig>(v.clone())
.ok()
.is_some_and(|c| c.has_providers())
});
match instance_config {
// An instance `ai_config` row with no usable provider (e.g. `{}`
// or `{"providers":{}}`) is treated as unconfigured, exactly as
// build_copilot_settings_state does — otherwise its mere presence
// would suppress the free-tier fallback below.
Some(config) if instance_has_config => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
_ => {
// Nothing configured: fall back to Windmill's free AI tier
// (EE-only) if a lent key is set and both the user's
// one-time grant and the instance's daily cap have room.
// Errors once the grant is spent, the day is capped, or the
// user already has a request in flight; None otherwise.
// Ineligible identities (e.g. service accounts) are refused
// inside the helper, so every path treats them alike.
let free =
crate::ai_free_tier_oss::resolve_free_tier_credentials(
&provider,
&db,
&ai_path,
&authed.email,
&body,
)
.await?;
if let Some((free_credentials, lease)) = free {
free_lease = Some(lease);
break 'cred free_credentials;
}
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
let mut tx = user_db.clone().begin(&authed).await?;
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
@@ -1112,38 +1166,45 @@ async fn proxy(
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
// Enforce RLS on $var: resolution when the resource path was
// user-specified (X-Resource-Path header) so users can only read
// variables they have permission to access.
let enforce_authed = if is_user_specified_resource {
Some(&authed)
} else {
None
};
let credentials = resolve_provider_credentials(
&provider,
&db,
&resource_workspace,
resource,
enforce_authed,
)
.await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringProviderCredentials::new(
credentials.clone(),
instance_ai_config_revision,
),
);
// Enforce RLS on $var: resolution when the resource path was
// user-specified (X-Resource-Path header) so users can only read
// variables they have permission to access.
let enforce_authed = if is_user_specified_resource {
Some(&authed)
} else {
None
};
let credentials = resolve_provider_credentials(
&provider,
&db,
&resource_workspace,
resource,
enforce_authed,
)
.await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringProviderCredentials::new(
credentials.clone(),
instance_ai_config_revision,
),
);
}
credentials
}
credentials
}
};
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
// since the request body is otherwise client-controlled.
if free_lease.is_some() {
body = crate::ai_free_tier_oss::enforce_free_tier_body(&body)?;
}
if let Some(fim_transform) =
maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)?
{
@@ -1291,8 +1352,32 @@ async fn proxy(
let status_code = response.status();
let headers = response.headers().clone();
let is_sse = is_sse_response(&headers);
// Free tier: reconcile the cost reserved up-front against what the response actually
// used, holding the per-user lock (via the lease) until it is recorded. The chat
// streams (SSE), where the usage report only arrives in the final chunk; the
// non-streaming JSON path is handled for completeness.
if let Some(lease) = free_lease {
let body = if is_sse {
axum::body::Body::from_stream(inject_keepalives(
Box::pin(crate::ai_free_tier_oss::meter_usage(
response.bytes_stream(),
db.clone(),
lease,
)),
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
))
} else {
let bytes = response.bytes().await.map_err(to_anyhow)?;
crate::ai_free_tier_oss::record_json_usage(db.clone(), lease, &bytes);
axum::body::Body::from(bytes)
};
return Ok((status_code, headers, body));
}
let stream = response.bytes_stream();
let body = if is_sse_response(&headers) {
let body = if is_sse {
axum::body::Body::from_stream(inject_keepalives(
stream,
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
@@ -0,0 +1,65 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::ai_free_tier_ee::*;
// Open-source build: Windmill's free AI tier does not exist. These stubs make the
// callers in `ai.rs` / `workspaces.rs` compile while disabling the feature entirely —
// `resolve_free_tier_credentials` never opts in, so the proxy falls through to its
// normal "AI resource not configured" path and the copilot stays hidden.
//
// Caller contract (enforced by the private impl, restated here for parity): the `email`
// passed to `resolve_free_tier_credentials` / `free_tier_copilot_config` MUST be the
// authenticated caller's own identity (an `ApiAuthed` email), never a client-supplied one —
// it selects whose lent-key grant is spent and whose usage is read.
#[cfg(not(feature = "private"))]
use crate::ai::AIConfig;
#[cfg(not(feature = "private"))]
use crate::db::DB;
#[cfg(not(feature = "private"))]
use axum::body::Bytes;
#[cfg(not(feature = "private"))]
use windmill_ai::ai_providers::AIProvider;
#[cfg(not(feature = "private"))]
use windmill_ai::credentials::ProviderCredentials;
#[cfg(not(feature = "private"))]
use windmill_common::error::Result;
#[cfg(not(feature = "private"))]
pub struct FreeTierLease;
#[cfg(not(feature = "private"))]
pub async fn resolve_free_tier_credentials(
_provider: &AIProvider,
_db: &DB,
_ai_path: &str,
_email: &str,
_body: &Bytes,
) -> Result<Option<(ProviderCredentials, FreeTierLease)>> {
Ok(None)
}
#[cfg(not(feature = "private"))]
pub fn enforce_free_tier_body(body: &Bytes) -> Result<Bytes> {
Ok(body.clone())
}
#[cfg(not(feature = "private"))]
pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result<Option<AIConfig>> {
Ok(None)
}
#[cfg(not(feature = "private"))]
pub fn record_json_usage(_db: DB, _lease: FreeTierLease, _bytes: &[u8]) {}
#[cfg(not(feature = "private"))]
pub fn meter_usage<S>(
upstream: S,
_db: DB,
_lease: FreeTierLease,
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
where
S: futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>> + Unpin,
{
upstream
}
+3
View File
@@ -66,6 +66,9 @@ use crate::scim_oss::has_scim_token;
use windmill_common::error::AppError;
mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_skills;
mod apps;
mod apps_raw_bundle;
+47 -11
View File
@@ -18,7 +18,6 @@ use crate::teams_oss::{
connect_teams, edit_teams_command, run_teams_message_test_job,
workspaces_list_available_teams_channels, workspaces_list_available_teams_ids,
};
use axum::{
extract::{Extension, Path},
routing::{get, post},
@@ -153,10 +152,23 @@ async fn edit_copilot_config(
.await?;
let settings_state =
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
// A provider-less instance config (e.g. `{}`) is unconfigured, same as build_copilot_settings_state
// treats it — so it must not shadow the free-tier fallback here either.
let instance_config_with_providers = instance_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.filter(|c| c.has_providers());
let effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_ai_config) = instance_ai_config {
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
} else if let Some(instance_config) = instance_config_with_providers {
instance_config
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
// Same fallback as get_copilot_info: with nothing configured, surface Windmill's free
// tier (EE-only) so clearing a workspace provider activates it immediately, instead of
// returning an empty config that disables AI until the next page reload re-fetches it.
free_config
} else {
AIConfig::default()
};
@@ -179,6 +191,7 @@ struct EditCopilotConfigResponse {
}
async fn get_copilot_info(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<AIConfig> {
@@ -194,16 +207,25 @@ async fn get_copilot_info(
))
})?;
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) =
let instance_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?
.and_then(|v| serde_json::from_value::<AIConfig>(v).ok())
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
// free-tier fallback, matching the proxy and edit_copilot_config paths.
.filter(|c| c.has_providers());
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) = instance_config {
Ok(Json(instance_config))
} else if let Some(free_config) =
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
{
Ok(Json(
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
))
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
// carries a `free_tier` marker even once the user's grant is spent — with no
// providers, but telling the client *why* AI is off.
Ok(Json(free_config))
} else {
Ok(Json(AIConfig::default()))
}
@@ -216,7 +238,14 @@ pub async fn get_critical_alerts(
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<serde_json::Value> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
require_admin_or_devops(
authed.is_admin,
&authed.username,
&authed.email,
authed.job_id.is_some(),
&db,
)
.await?;
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
}
@@ -232,7 +261,14 @@ pub async fn acknowledge_critical_alert(
Path((w_id, id)): Path<(String, i32)>,
authed: ApiAuthed,
) -> Result<String> {
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
require_admin_or_devops(
authed.is_admin,
&authed.username,
&authed.email,
authed.job_id.is_some(),
&db,
)
.await?;
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.799.0";
export const VERSION = "v1.800.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.799.0";
export const VERSION = "1.800.0";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.799.0",
"version": "1.800.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+11 -2
View File
@@ -7,6 +7,7 @@ import {
type AIProviderModel,
type AIProvider,
type AIConfig,
type FreeTierInfo,
type ModelPriceOverride
} from './gen'
import {
@@ -49,6 +50,10 @@ export const copilotInfo = writable<{
/** Negotiated rates per `provider:model`, overriding the built-in price table. */
modelPricing?: Record<string, ModelPriceOverride>
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
// Set only when the workspace has no AI provider of its own and is running on
// Windmill's free tier. `exhausted` means the grant is spent: there is no model, but
// that is a different state from "never configured" and the UI must say so.
freeTier?: FreeTierInfo
}>({
enabled: false,
codeCompletionModel: undefined,
@@ -132,8 +137,9 @@ export function setCopilotInfo(aiConfig: AIConfig) {
aiModels: aiModels,
customPrompts: aiConfig.custom_prompts ?? {},
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
webSearchEnabledProviders,
modelPricing: aiConfig.model_pricing ?? {},
webSearchEnabledProviders
freeTier: aiConfig.free_tier
})
} else {
copilotSessionModel.set(undefined)
@@ -146,8 +152,11 @@ export function setCopilotInfo(aiConfig: AIConfig) {
aiModels: [],
customPrompts: {},
maxTokensPerModel: {},
webSearchEnabledProviders: {},
modelPricing: {},
webSearchEnabledProviders: {}
// An exhausted free grant lands here — no providers, but the reason AI is off
// is "you used it up", not "you never set it up".
freeTier: aiConfig.free_tier
})
}
}
@@ -9,6 +9,11 @@
type: 'string' | 'number' | 'boolean'
allowMultiple?: boolean
format?: 'json'
/** Boolean only: the value the filter holds while unset (defaults to false).
* Selecting a filter whose default is false sets it to true immediately rather
* than opening a true/false picker whose only useful choice is true. A default-true
* boolean still opens the picker, since choosing false is the meaningful action. */
default?: boolean
}
| {
type: 'date'
@@ -121,16 +126,34 @@
// Create the filter instance object
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
// Sync URL params to filter instance on initialization and when URL changes
// Sync URL params to filter instance, reactively. Reading urlFilter[key] tracked
// means browser Back/Forward — which mutates useSearchParams' cells on popstate —
// flows into the instance (chips, kind toggle, results), not just the first render.
// The write happens untracked so it can't self-trigger, and the equality check plus
// the reverse effect's own guard keep the two directions from ping-ponging.
for (const key of Object.keys(schemaRec)) {
let urlValue = urlFilter[key]
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
const d = new Date(urlValue)
urlValue = isNaN(d.getTime()) ? null : d
}
if (urlValue !== undefined && urlValue !== null) {
;(filterInstance.val as any)[key] = urlValue
}
$effect(() => {
let urlValue = urlFilter[key]
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
const d = new Date(urlValue)
urlValue = isNaN(d.getTime()) ? null : d
}
untrack(() => {
const current = (filterInstance.val as any)[key]
const same =
urlValue instanceof Date && current instanceof Date
? urlValue.getTime() === current.getTime()
: current === (urlValue ?? undefined)
if (same) return
if (urlValue !== undefined && urlValue !== null) {
;(filterInstance.val as any)[key] = urlValue
} else if (current !== undefined) {
// Key dropped from the URL (Back to a state without it): clear it so a
// stale chip / filter doesn't linger against the navigated-to URL.
delete (filterInstance.val as any)[key]
}
})
})
}
// Sync filter instance changes back to URL params
@@ -275,6 +298,17 @@
class?: string
placeholder?: string
autofocus?: boolean
// Applied as the id of the underlying editable, so a parent can focus it or recognise its
// key events by id (the searchbar is a contenteditable, not an <input>).
inputId?: string
// Free-text mode: while the input holds only free text (no specific filter tag is
// being edited and no non-default filter is set), suppress the suggestions dropdown
// so it behaves like a plain search box. This frees the arrow keys for the
// surrounding UI (e.g. a results list). The dropdown returns the moment a specific
// filter is present (e.g. `path: u/me/abc`).
hideDropdownOnFreeText?: boolean
// Notified whenever the dropdown's effective visibility changes
onDropdownVisibleChange?: (visible: boolean) => void
}
type SchemaT = FilterSchemaRec // TODO: Generic
@@ -284,7 +318,10 @@
presets: _presets = [],
class: className,
placeholder = 'Filter...',
autofocus
autofocus,
hideDropdownOnFreeText = false,
onDropdownVisibleChange,
inputId
}: Props<SchemaT> = $props()
let _value = new DebouncedTempValue(
@@ -298,6 +335,24 @@
let currentTag: keyof SchemaT | undefined = $state()
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
let open = $state(false)
// A specific filter is in play when a tag is being edited or any non-free-text filter
// is set.
let hasSpecificFilter = $derived(
!!currentTag || Object.keys(value).some((k) => k !== '_default_')
)
// A plain search term is being typed (free text, no specific filter).
let hasFreeText = $derived(!!String(value['_default_'] ?? '').trim())
// Effective dropdown visibility. Free-text mode suppresses the dropdown ONLY while the
// user is typing a plain search term: it still opens when the input is empty (so the
// available filters stay discoverable) and whenever a specific filter is set or being
// edited. That leaves the arrow keys for the surrounding list only during free-text search.
let dropdownVisible = $derived(
open && (!hideDropdownOnFreeText || hasSpecificFilter || !hasFreeText)
)
$effect(() => {
onDropdownVisibleChange?.(dropdownVisible)
})
let inputElement: HTMLDivElement | undefined = $state()
let highlightedIndex = $state(0)
let taggedTextInput: TaggedTextInput | undefined = $state()
@@ -347,9 +402,17 @@
key,
filterSchema,
onClick: () => {
// Replace the text segment with the new filter tag
const before = asText.val.slice(0, currentTextSegment.start)
const after = asText.val.slice(currentTextSegment.end)
if (schema[key].type === 'boolean' && schema[key].default !== true) {
// Set the only useful value and reparse to canonical text. The space is
// required: dropping the segment must not fuse the tags that flanked it.
asText.val = `${before} ${after}`
value[key] = true as any
asText.reparse()
return
}
// Replace the text segment with the new (empty) filter tag; the value picker opens.
asText.val =
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
'\u00A0'
@@ -406,6 +469,28 @@
onClick: () => setValueForCurrentTag(false)
}
]
} else if (filter.type === 'string' && filter.format !== 'json') {
// A plain string filter has no fixed options, but any presets targeting this tag
// (`<tag>:<value>`) are exactly its useful values — surface them as suggestions so
// picking one is a click, matching the top-level preset row. Unescape the tagged
// syntax's `\ ` back to a real space for the stored value.
const prefix = `${String(currentTag)}:`
const suffix = String(value[currentTag!] ?? '')
.trim()
.toLowerCase()
return _presets
.filter((p) => p.value.startsWith(prefix) && !asText.val.includes(p.value))
.map((p) => {
const raw = p.value.slice(prefix.length).replace(/^\\ /, '').replace(/\\ /g, ' ')
return { name: p.name, raw }
})
.filter((p) => !suffix || p.raw.toLowerCase().includes(suffix))
.map((p) => ({
type: 'option' as const,
option: { value: p.raw, label: p.name },
onClick: () => appendOrSetValueForCurrentTag(p.raw),
onNegativeClick: undefined
}))
}
}
return []
@@ -514,7 +599,9 @@
}
function handleKeyDown(e: KeyboardEvent) {
if (!open) return
// In free-text mode the dropdown is hidden; let arrow/enter keys pass through to
// the surrounding UI (e.g. list navigation) rather than steering a hidden menu.
if (!dropdownVisible) return
if (e.key === 'Escape') {
open = false
return
@@ -601,6 +688,7 @@
>
<TaggedTextInput
bind:this={taggedTextInput}
id={inputId}
bind:value={asText.val}
{tags}
highlights={[
@@ -617,7 +705,19 @@
inputSizeClasses.md
)}
{placeholder}
onKeyDown={() => (open = true)}
onKeyDown={(e) => {
// In free-text mode the searchbar coexists with a list that owns Arrow/Enter, so opening
// the dropdown on a bare navigation key would steal them from an empty box. Typing, click,
// or an already-open dropdown still open/keep it. Other searchbars keep opening on any key.
if (
!hideDropdownOnFreeText ||
!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'].includes(
e.key
)
) {
open = true
}
}}
{autofocus}
/>
{#if asText.val}
@@ -630,9 +730,10 @@
</div>
<GenericDropdown
{open}
open={dropdownVisible}
instantClose={hideDropdownOnFreeText}
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
innerClass="!max-h-[30rem]"
innerClass="!max-h-[25rem]"
strictWidth
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -747,6 +848,20 @@
class="border border-border-light rounded min-h-[4rem]"
/>
</div>
{:else if filter.type === 'string'}
{#if menuItems.length}
<div class="max-h-60 overflow-y-auto">
{#each menuItems as item, index}
{#if item.type === 'option' && item.option}
{@render menuItem({
onClick: item.onClick,
label: item.option.label || item.option.value,
highlighted: index === highlightedIndex
})}
{/if}
{/each}
</div>
{/if}
{/if}
{/snippet}
@@ -8,6 +8,7 @@
onTextSegmentAtCursorChange,
onKeyDown,
autofocus,
id,
class: className = ''
}: {
tags: { regex: RegExp; id: string; onClear?: () => void }[]
@@ -18,6 +19,7 @@
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
onKeyDown?: (e: KeyboardEvent) => void
autofocus?: boolean
id?: string
class?: string
} = $props()
@@ -337,7 +339,13 @@
function handleKeyDown(e: KeyboardEvent) {
onKeyDown?.(e)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
// Single-line filter input: block Enter's default newline insertion. Surrounding handlers
// (suggestion select, list open) still run on bubble; only the contenteditable break is gone.
if (e.key === 'Enter') {
e.preventDefault()
return
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') return
const cursorPos = getCursorPosition()
const text = getTextContent()
@@ -509,6 +517,7 @@
<!-- svelte-ignore a11y_autofocus -->
<div
bind:this={contentEditableDiv}
{id}
contenteditable="true"
oninput={handleInput}
onpaste={handlePaste}
@@ -27,18 +27,14 @@
let trail = $derived(
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
)
let description = $derived(
location
? undefined
: 'Each run answers a dataset of cases with this agent and scores the answers, so runs can be compared.'
)
</script>
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
<Modal
title={TITLE}
{trail}
{description}
paginated
enterConfirms={false}
bind:open
kind="X"
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
@@ -1,82 +1,76 @@
<script lang="ts">
import { createGrid, type GridApi } from 'ag-grid-community'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
import { transformColumnDefs } from '$lib/components/apps/components/display/table/utils'
import { multilineCellColDef } from '$lib/components/apps/components/display/table/multilineCellEditor'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import Row from '$lib/components/table/Row.svelte'
import EditableTextarea from '$lib/components/common/EditableTextarea.svelte'
import { Button, EmptyState } from '$lib/components/common'
import { ListPlus, Plus, Trash2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import type { CaseDraft } from './evalUtils'
let {
cases = $bindable(),
onRemove,
locked = false,
onEditingChange
onAdd,
focusCaseId = undefined,
locked = false
}: {
/** The drawer's working copy. Edits land here as they are made; the drawer writes them. */
cases: CaseDraft[]
/** Asked before a row goes, since a stored case has runs that executed it. */
onRemove: (c: CaseDraft) => void
/** Adds a case. The empty table offers this itself, where the first row would be. */
onAdd: () => void
/** A case to open for typing as soon as it appears — the one just added, so a new row is
* ready to be filled in rather than waiting to be clicked. */
focusCaseId?: string
/** The cases are being written: an edit made now is one the request already left behind. */
locked?: boolean
/** A cell opened or closed. Reported up so Save can be pressed for an edit the list does not
* hold yet: the press is what commits the cell, so it has to reach a live button. */
onEditingChange?: (editing: boolean) => void
} = $props()
type Row = { id: string; question: string; expected: string }
/** A dataset is capped at 1000 cases and every row here mounts two editors and a button, so the
* whole set at once is thousands of components for a table you read a screenful of. ag-grid
* virtualised its rows; `DataTable` paginates instead, which is the primitive already here. */
const CASES_PER_PAGE = 25
let currentPage = $state(1)
/** Bound to `DataTable`'s own per-page selector, so changing it there re-slices here rather
* than only relabelling the footer. */
let perPage = $state(CASES_PER_PAGE)
let lastPage = $derived(Math.max(1, Math.ceil(cases.length / perPage)))
let pageCases = $derived(cases.slice((currentPage - 1) * perPage, currentPage * perPage))
// A case removed from the last page leaves it empty; step back rather than show nothing.
$effect(() => {
if (currentPage > lastPage) currentPage = lastPage
})
/** `expected` is whatever the case holds: a bare string, or a value shown as JSON. Read back
* the same way, so a case that held an object keeps holding one. */
function expectedToText(value: unknown): string {
if (value == undefined) return ''
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function toRow(c: CaseDraft): Row {
return {
id: c.id ?? '',
question: c.input?.user_message ?? '',
expected: expectedToText(c.expected)
}
}
let api: GridApi<any> | undefined = $state()
let eGui: HTMLDivElement | undefined = $state()
let darkMode = $state(false)
const defaultColDef = {
flex: 1,
minWidth: 120,
...multilineCellColDef
}
$effect(() => eGui && untrack(() => mountGrid()))
function mountGrid() {
if (!eGui || api) return
createGrid(eGui, {
rowData: untrack(() => cases.map(toRow)),
columnDefs: columnDefs(),
defaultColDef: { ...defaultColDef, editable: untrack(() => !locked) },
onCellValueChanged: (e) => {
const target = cases.find((c) => c.id === (e.data as Row).id)
if (!target) return
if (e.colDef.field === 'question') {
target.input = { ...target.input, user_message: e.newValue ?? '' }
} else if (e.colDef.field === 'expected') {
setExpected(target, e.newValue ?? '')
}
},
onCellEditingStarted: () => onEditingChange?.(true),
onCellEditingStopped: () => onEditingChange?.(false),
suppressColumnMoveAnimation: true,
suppressDragLeaveHidesColumns: true,
onGridReady: (e) => (api = e.api)
/** The question editors, by case id, so a newly added row can be opened. */
let questionEditors = $state<Record<string, EditableTextarea | undefined>>({})
$effect(() => {
const id = focusCaseId
if (!id) return
untrack(() => {
// Onto the page the case is on before reaching for its editor: paginated, a case added to
// a full page is not rendered yet, and there would be nothing to open.
const at = cases.findIndex((c) => c.id === id)
if (at < 0) return
currentPage = Math.floor(at / perPage) + 1
// After the row has been rendered and registered itself.
requestAnimationFrame(() => questionEditors[id]?.edit())
})
}
})
/** Text that parses as JSON is stored as JSON, so a structured answer can be written by hand. */
function setExpected(c: CaseDraft, text: string) {
// Cleared means the case has no expected answer, which is not the same as expecting the
// empty string: a scorer reads `undefined` as "nothing to measure here" and leaves the case
// out of its mean, where `''` scores it a hard zero.
const trimmed = text.trim()
if (!trimmed) {
c.expected = undefined
@@ -88,45 +82,93 @@
c.expected = text
}
}
function columnDefs() {
return transformColumnDefs({
columnDefs: [
{ field: 'question', headerName: 'Question', flex: 3 },
{ field: 'expected', headerName: 'Expected', flex: 2 }
] as any,
onDelete: (values) => {
if (locked) return
const target = cases.find((c) => c.id === (values as Row).id)
if (target) onRemove(target)
}
})
}
// Keyed on which cases are in the list rather than on what is in them: pushing rows back on
// each keystroke would reset the cell being typed in.
let rowKey = $derived(cases.map((c) => c.id).join(','))
$effect(() => {
rowKey
untrack(() => api?.updateGridOptions({ rowData: cases.map(toRow) }))
})
$effect(() => {
const editable = !locked
untrack(() => api?.updateGridOptions({ defaultColDef: { ...defaultColDef, editable } }))
})
/** Commit whatever cell is open into `cases`, and wait for it to land there: the drawer reads
* the list to save it, and a cell still being typed in is an edit that press is saving. */
export async function flush() {
api?.stopEditing()
// The commit reaches `cases` through the grid's own event queue, a macrotask away: reading in
// the same turn, or after a microtask, reads the list as it was before the cell was typed in.
await new Promise((resolve) => setTimeout(resolve, 0))
}
</script>
<DarkModeObserver bind:darkMode />
<div bind:this={eGui} class="h-full w-full ag-theme-alpine {darkMode ? 'ag-theme-alpine-dark' : ''}"
></div>
{#if cases.length === 0}
<EmptyState
icon={ListPlus}
title="No cases yet"
description="A case is one question this agent is asked, and the answer it is measured against. A dataset needs at least one before it can be saved or run."
action={{
label: 'Add a case',
icon: Plus,
onClick: onAdd,
// The same lock the header's own button takes: a save reads the case list before it is
// sent, so a case added while one is in flight would be dropped when it lands.
disabled: locked,
// The only live call to action here: Save stays disabled until a case exists.
variant: 'accent'
}}
/>
{:else}
<DataTable
size="xs"
tableFixed
containerClass="bg-surface-tertiary"
paginated={cases.length > perPage}
bind:currentPage
bind:perPage
rowCount={cases.length}
hasMore={currentPage < lastPage}
showPrev={currentPage > 1}
on:next={() => (currentPage = Math.min(currentPage + 1, lastPage))}
on:previous={() => (currentPage = Math.max(currentPage - 1, 1))}
>
<colgroup>
<col style="width: 55%" />
<col />
<col style="width: 3rem" />
</colgroup>
<Head>
<tr>
<Cell head first>Question</Cell>
<Cell head>Expected</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each pageCases as c (c.id)}
<Row>
<Cell first>
<!-- `commitOnInput`: the drawer's Save reads this list, so an edit has to be in it by
the time the button is pressed rather than waiting on a blur that the press
itself would cause. -->
<EditableTextarea
bind:this={questionEditors[c.id ?? '']}
value={c.input?.user_message ?? ''}
placeholder="Question"
editable={!locked}
commitOnInput
class="w-full"
textClass="text-xs font-normal"
onSave={(v) => (c.input = { ...(c.input ?? {}), user_message: v })}
/>
</Cell>
<Cell>
<EditableTextarea
value={expectedToText(c.expected)}
placeholder="Expected"
editable={!locked}
commitOnInput
class="w-full"
textClass="text-xs font-normal"
onSave={(v) => setExpected(c, v)}
/>
</Cell>
<Cell last numeric>
<Button
unifiedSize="sm"
variant="subtle"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
title="Delete case"
disabled={locked}
onclick={() => onRemove(c)}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
@@ -26,8 +26,7 @@
onRenamed,
onDeleted,
onCasesChanged,
onScorersChanged,
onClosed
onScorersChanged
}: {
workspace: string | undefined
/** The agent the dataset is named after and belongs to. */
@@ -45,8 +44,6 @@
onDeleted: (path: string) => void | Promise<void>
onCasesChanged: () => void | Promise<void>
onScorersChanged: () => void | Promise<void>
/** The drawer is done, whether it saved anything or not. */
onClosed?: () => void
} = $props()
let drawer: Drawer | undefined = $state()
@@ -92,11 +89,6 @@
let storedIds = $state<Set<string>>(new Set())
let removingCase = $state<CaseDraft | undefined>(undefined)
/** The grid, so an open cell can be committed before the list is read to write it. */
let casesGrid: EvalCasesGrid | undefined = $state()
/** A cell is open in the grid: an edit the list does not hold yet, so Save stays live for the
* press that commits it. */
let casesEditing = $state(false)
/** The next free `<agent>_datasetN`, which is what a dataset is called until it is named. */
function nextDatasetIndex(): number {
@@ -109,9 +101,9 @@
export function openDrawer(next: 'new' | 'edit') {
mode = next
pathError = ''
// The grid is rebuilt per open and reports afresh; a cell left open when the drawer closed
// would otherwise leave Save live over a set nothing has changed.
casesEditing = false
// Or the case added last time opens itself when the drawer is next opened on this dataset:
// ids survive `fromStoredCase`, so the id would still match a row.
focusCaseId = undefined
// What was collected for a dataset that was never created belongs to that attempt.
pendingScorers = []
workingCases = next === 'edit' ? cases.map((c) => fromStoredCase(c) as CaseDraft) : []
@@ -145,7 +137,6 @@
async function createDataset() {
if (!workspace || !path || pathError) return
await casesGrid?.flush()
creating = true
const created = path
const submittedSummary = summary || undefined
@@ -164,9 +155,9 @@
creating = false
return
}
// The pane moves onto the created dataset before the drawer closes: closing reopens the Run
// dialog, which reads the pane's selection as it opens. A refresh that fails must still not
// read as a create that failed, or the retry hits "already exists".
// The pane moves onto the created dataset while the drawer is still open the Run dialog
// stands underneath and follows that selection. A refresh that fails must still not read as
// a create that failed, or the retry hits "already exists".
try {
await onCreated(created)
} catch (e) {
@@ -193,8 +184,6 @@
* refuses the case edits with it instead of leaving them written under the old name. */
async function saveDataset() {
if (!workspace || !datasetPath || !dataset || !path || pathError) return
// Before anything reads the list: a cell still open is an edit that must go in with it.
await casesGrid?.flush()
// Read once, so every step of the save agrees on what was submitted: the fields are locked
// while it runs, and the one the drawer navigates to afterwards must be the one written.
// `summary` always travels: the server keeps the stored one when it is absent, so clearing
@@ -233,8 +222,13 @@
saving = false
}
/** The case the grid should open for typing: the one just added. */
let focusCaseId = $state<string | undefined>(undefined)
function addCase() {
workingCases = [...workingCases, { ...emptyCase(), id: randomUUID() }]
const id = randomUUID()
workingCases = [...workingCases, { ...emptyCase(), id }]
focusCaseId = id
}
function deleteCase(id: string) {
@@ -263,9 +257,15 @@
)
})
let nothingToSave = $derived(metadataUnchanged && !casesChanged)
/** A dataset with no cases cannot be run — the server refuses it (`run.rs`, "has no case to
* run") — so there is no point creating one. Only creation is blocked: a saved dataset must
* stay editable down to its last case, or removing that case, or renaming while it is the only
* one left, would have no way to be saved. */
let noCases = $derived(workingCases.length === 0)
let noCasesTitle = $derived(noCases ? 'Add at least one case first' : undefined)
</script>
<Drawer bind:this={drawer} size="900px" on:close={() => onClosed?.()}>
<Drawer bind:this={drawer} size="900px">
<!-- Inside the drawer, not beside it. The drawer is portalled to the body and stacked above the
pane that opened it, so a modal rendered from here is otherwise trapped under it. -->
<ConfirmationModal
@@ -354,7 +354,10 @@
onWriting={(w) => (scorersWriting = w)}
/>
</div>
<div class="flex flex-col gap-2 grow min-h-0">
<!-- Sized by its rows, not by what is left of the drawer: a dataset with one case should
not be followed by an empty half-screen of table. The drawer body scrolls when the
list outgrows it. -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-emphasis">Cases</span>
<span class="text-2xs text-tertiary">{workingCases.length}</span>
@@ -369,13 +372,13 @@
Add a case
</Button>
</div>
<div class="grow min-h-0">
<div>
<EvalCasesGrid
bind:this={casesGrid}
bind:cases={workingCases}
onRemove={removeCase}
onAdd={addCase}
{focusCaseId}
locked={writing}
onEditingChange={(v) => (casesEditing = v)}
/>
</div>
</div>
@@ -397,7 +400,7 @@
unifiedSize="md"
variant="accent"
loading={saving}
disabled={writing || !path || !!pathError || (nothingToSave && !casesEditing)}
disabled={writing || !path || !!pathError || nothingToSave}
onclick={saveDataset}
>
Save
@@ -408,7 +411,8 @@
variant="accent"
startIcon={{ icon: Plus }}
loading={creating}
disabled={creating || !path || !!pathError}
disabled={creating || !path || !!pathError || noCases}
title={noCasesTitle}
onclick={createDataset}
>
Create dataset
@@ -47,16 +47,6 @@
let hasDraft = $derived(editedConfig !== undefined)
let dataset = $state<string | undefined>(undefined)
let hoveringDataset = $state(false)
/** Set while the dialog stands aside for the dataset drawer, holding the pane's dataset as it
* was on the way out: coming back onto a different one is the detour's answer, and adopted. */
let steppedAside = $state<{ dataset: string | undefined } | undefined>(undefined)
/** Hands the screen to the dataset drawer, to be given back when it closes. */
function stepAside(go: () => void) {
steppedAside = { dataset: defaultDataset }
open = false
go()
}
/** The agent's versions, for pinning one. Loaded when the dialog opens rather than held: a
* version list goes stale the moment the agent is saved again. */
@@ -76,21 +66,26 @@
}
}
/** Whether the dialog was already open on the previous run of the effect below, which reads it
* to tell an open from the pane's dataset moving underneath. */
let wasOpen = false
$effect(() => {
if (!open) return
const isOpen = open
const pane = defaultDataset
untrack(() => {
loadVersions()
const aside = steppedAside
steppedAside = undefined
if (aside) {
// Back from the drawer: a dataset created or edited there moves the pane onto it, and
// anything else leaves the field as it was left.
if (defaultDataset !== aside.dataset) dataset = defaultDataset
if (!isOpen) {
wasOpen = false
return
}
// Seeded on every open: the dataset last worked in, and the state of the agent there is
// most reason to measure.
dataset = defaultDataset
// Followed while the dialog stands, not only read at open: the dataset drawer opens over
// this dialog rather than in place of it, so creating, renaming or deleting a dataset
// there moves the pane's selection with the dialog still up. Nothing else moves it then.
dataset = pane
if (wasOpen) return
wasOpen = true
loadVersions()
// The state of the agent there is most reason to measure.
choice = hasDraft ? 'draft' : 'deployed'
})
})
@@ -184,7 +179,7 @@
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => stepAside(onNewDataset)}
onclick={onNewDataset}
>
New dataset
</Button>
@@ -215,7 +210,7 @@
title="Edit this dataset"
on:click={() => {
close()
stepAside(() => onEditDataset(item.value ?? ''))
onEditDataset(item.value ?? '')
}}
/>
{/snippet}
@@ -228,7 +223,7 @@
btnClasses="w-full !h-auto !justify-start !rounded-none flex items-center gap-2 px-3 py-2 text-xs !font-normal text-secondary hover:bg-surface-hover"
onClick={() => {
close()
stepAside(onNewDataset)
onNewDataset()
}}
>
<Plus size={13} />
@@ -247,7 +242,7 @@
startIcon={{ icon: Pencil }}
iconOnly
title="Edit this dataset"
on:click={() => stepAside(() => onEditDataset(dataset ?? ''))}
on:click={() => onEditDataset(dataset ?? '')}
/>
</div>
{/if}
@@ -7,17 +7,21 @@
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import { Button } from '$lib/components/common'
import { Bot, Code2, Loader2, Plus } from 'lucide-svelte'
import { Bot, ChevronRight, Code2, Loader2, Plus } from 'lucide-svelte'
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
import type { EvalDataset, EvalExperiment, ExperimentScore } from '$lib/gen'
import { datasetSummary, experimentName, formatScore, subjectLabel } from './evalUtils'
let {
experiments,
datasets,
caseProgress,
loaded,
active = false,
deployedHash = undefined,
currentVersion = undefined,
onOpen,
onHighlight,
onEditDataset,
onNew
}: {
@@ -25,17 +29,87 @@
experiments: EvalExperiment[]
/** Whether the list has been read: an empty table is a statement about the agent. */
loaded: boolean
/** Whether this list is the page on screen. The keyboard is only answered while it is: the
* run page keeps its own rows, and both would otherwise move on one press. */
active?: boolean
/** The workspace's datasets, for naming the one a run is of by what it is for. */
datasets: EvalDataset[]
/** How many cases each still-running run has finished, keyed by run id. A run the flow has
* not been read for yet is at none of them rather than absent: the count is on the row from
* the moment it appears, so it never arrives late and shifts the column. */
caseProgress: Record<string, number>
/** What the agent hashes to as deployed, and the version it is on: they resolve a run of
* edits that were later saved, so a run is labelled here as the run picker labels it. */
deployedHash?: string
currentVersion?: number
onOpen: (experiment: EvalExperiment) => void
/** The highlighted run, reported up so the surface can act on it — arrowing into the run
* page opens the run under the highlight rather than whichever was opened last. */
onHighlight?: (id: string | undefined) => void
onEditDataset: (dataset: string) => void
onNew: () => void
} = $props()
/** The highlighted run, by id. One state for both the pointer and the keyboard, as a melt menu
* does it: hovering a row moves the highlight to it, so the arrows carry on from wherever the
* pointer left off instead of running a second, invisible cursor of their own. It says where
* the highlight is, not what is chosen — a run is not opened until Enter.
*
* By id and not by index: the list is newest-first and the poll prepends to it, so an index
* would quietly come to mean a different run and Enter would open the wrong one. */
let cursorId = $state<string | undefined>(undefined)
let cursor = $derived(
cursorId === undefined ? -1 : experiments.findIndex((e) => e.id === cursorId)
)
let body: HTMLTableSectionElement | undefined = $state()
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
// still the one on top, rather than under a drawer or a dialog opened since.
const hostActive = overlayHostActive()
const onTop = topmostSurface()
const listening = () => hostActive() && onTop()
$effect(() => {
onHighlight?.(cursorId)
})
// A highlight on a run that has since gone, and the highlight itself when the list is not the
// page on screen.
$effect(() => {
if (!active || (cursorId !== undefined && cursor < 0)) cursorId = undefined
})
function move(by: number) {
if (experiments.length === 0) return
const from = cursor < 0 ? (by > 0 ? -1 : experiments.length) : cursor
const at = Math.max(0, Math.min(experiments.length - 1, from + by))
cursorId = experiments[at]?.id
// `nearest`, so arrowing through a long list scrolls by a row rather than jumping the table.
requestAnimationFrame(() =>
body?.querySelectorAll('tr')[at]?.scrollIntoView({ block: 'nearest' })
)
}
function onKeydown(event: KeyboardEvent) {
if (!active || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
const el = event.target as HTMLElement | null
if (el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')) return
if (event.key === 'ArrowDown') {
event.preventDefault()
move(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
move(-1)
} else if (event.key === 'Enter' && experiments[cursor]) {
// Enter belongs to whatever is focused if that thing does something with it. A highlighted
// row is not a reason to swallow the press on `New evaluation` or a row's dataset button.
if (el?.closest?.('button, a[href], [role="button"], summary')) return
event.preventDefault()
onOpen(experiments[cursor])
}
}
/** The one number a column reports: a pass rate where it has a line to pass, the mean where it
* does not. */
function headline(score: ExperimentScore): string | undefined {
@@ -44,13 +118,16 @@
}
</script>
<svelte:window onkeydown={onKeydown} />
<DataTable size="sm" tableFixed>
<colgroup>
<col style="width: 20%" />
<col style="width: 22%" />
<col style="width: 5rem" />
<col style="width: 6rem" />
<col />
<col style="width: 7rem" />
<col style="width: 2rem" />
</colgroup>
<Head>
<tr>
@@ -58,12 +135,20 @@
<Cell head>Dataset</Cell>
<Cell head numeric>Cases</Cell>
<Cell head>Scores</Cell>
<Cell head last numeric>When</Cell>
<Cell head numeric>When</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each experiments as experiment (experiment.id)}
<Row hoverable on:click={() => onOpen(experiment)}>
<tbody class="divide-y" bind:this={body}>
{#each experiments as experiment, i (experiment.id)}
<!-- No `hoverable`, and not `selected` either: the first is a second hover tint competing
with this highlight, the second is the blue that means "chosen". Nothing here is
chosen until Enter, so the highlight wears the ordinary hover surface. -->
<Row
class="cursor-pointer {i === cursor ? 'bg-surface-hover' : ''}"
on:click={() => onOpen(experiment)}
on:hover={(e) => e.detail && (cursorId = experiment.id)}
>
<Cell first>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
@@ -99,7 +184,18 @@
</Button>
</Cell>
<Cell numeric>
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
{#if experiment.running}
<!-- The run's only spinner. One per score badge instead read as the judge being
slow, when what is unfinished is the run. -->
<span
class="inline-flex items-center gap-1 tabular-nums text-secondary whitespace-nowrap"
>
<Loader2 size={11} class="animate-spin text-blue-500 shrink-0" />
{caseProgress[experiment.id] ?? 0}/{experiment.case_count}
</span>
{:else}
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
{/if}
</Cell>
<Cell>
<div class="flex flex-wrap gap-1 min-w-0">
@@ -117,8 +213,6 @@
<span class="tabular-nums font-semibold text-emphasis">{value}</span>
{:else if score.failed > 0}
<span class="text-red-500">failed</span>
{:else if experiment.running}
<Loader2 size={11} class="animate-spin text-blue-500 self-center" />
{:else}
<span class="text-tertiary"></span>
{/if}
@@ -126,33 +220,34 @@
</Badge>
{/each}
{#if (experiment.scores ?? []).length === 0}
{#if experiment.running}
<span class="text-2xs text-tertiary inline-flex items-center gap-1">
<Loader2 size={11} class="animate-spin text-blue-500" />
scoring
</span>
{:else}
<span class="text-2xs text-tertiary">not scored</span>
{/if}
<!-- Not a pending state: a scorer's column is on the run from the moment it is
created, with or without a number in it, so nothing arrives here later. -->
<span class="text-2xs text-tertiary">not scored</span>
{/if}
</div>
</Cell>
<Cell last numeric>
<Cell numeric>
<span class="text-2xs text-tertiary whitespace-nowrap">
<TimeAgo date={experiment.created_at} agoOnlyIfRecent />
</span>
</Cell>
<Cell last numeric>
<ChevronRight
size={14}
class="text-tertiary transition-opacity {i === cursor ? 'opacity-100' : 'opacity-0'}"
/>
</Cell>
</Row>
{/each}
{#if experiments.length === 0 && !loaded}
<tr>
<td colspan="5" class="p-3">
<td colspan="6" class="p-3">
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
</td>
</tr>
{:else if experiments.length === 0}
<tr>
<td colspan="5" class="p-6">
<td colspan="6" class="p-6">
<div class="flex flex-col items-center justify-center gap-3 text-center">
<span class="text-sm text-emphasis">No runs yet</span>
<span class="text-xs text-secondary max-w-md">
@@ -196,26 +196,31 @@
</DropdownV2>
</div>
<div class="border rounded-md">
<div>
{#if scorers.length === 0}
<div class="p-3 text-2xs text-tertiary">
<div class="border rounded-md p-3 text-2xs text-tertiary">
A scorer reads one run and returns a number. Every run of this dataset is measured by all of
them, which is what makes two runs comparable.
</div>
{:else}
<div class="divide-y">
<!-- Laid out as `RadioCard` is — same padding, icon size, and label-over-description type
scale — so a scorer reads as a card of the same family. Not that component: nothing
here is being selected. -->
<div class="flex flex-col gap-2">
{#each scorers as scorer (scorer.id)}
<div class="flex items-center gap-2 px-2 py-1.5">
<div
class="flex items-center gap-2 rounded-md border border-border-light bg-surface-tertiary p-3"
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
<Bot size={16} class="text-secondary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
<Code2 size={16} class="text-secondary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 grow">
<span class="text-xs text-emphasis truncate leading-tight">
<span class="text-xs font-semibold text-emphasis truncate">
{scorerLabel(scorer)}
</span>
<span class="text-2xs text-tertiary truncate leading-tight">{scorer.path}</span>
<span class="text-xs font-normal text-secondary truncate mt-0.5">{scorer.path}</span>
</div>
{#if scorer.pass_if != undefined}
<span class="text-2xs text-tertiary shrink-0" title="Pass threshold">
@@ -9,9 +9,11 @@
import Label from '$lib/components/Label.svelte'
import Popover from '$lib/components/Popover.svelte'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
import {
type AgentDraft,
AiEvalsService,
JobService,
type EvalCase,
type EvalDataset,
type EvalExperiment,
@@ -35,9 +37,11 @@
Code2,
ExternalLink
} from 'lucide-svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import EvalDatasetDrawer from './EvalDatasetDrawer.svelte'
import EvalRunsList from './EvalRunsList.svelte'
import EvalRunDialog from './EvalRunDialog.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import {
caseLabel,
@@ -52,6 +56,10 @@
/** A dataset is capped at this many cases, so one page holds the whole set. */
const CASE_PAGE_SIZE = 1000
/** The id the run's flow gives the loop over its cases (`CASES_NODE_ID` in `ai_evals/run.rs`).
* Looked up by id rather than by position: the flow has a step after the loop too. */
const CASES_MODULE_ID = 'cases'
let {
agentPath,
opWorkspace = undefined,
@@ -91,12 +99,56 @@
/** What the agent hashes to as deployed: a run of edits carrying it ran what was then saved. */
let deployedHash = $state<string | undefined>(undefined)
let running = $state(false)
let scorers = $derived(dataset?.scorers ?? [])
/** The run on screen belongs to a dataset still being read. Until it arrives the rows and the
* scorer columns would both be built from the *previous* dataset, so both are held back. */
let datasetLoading = $state(false)
let scorers = $derived(datasetLoading ? [] : (dataset?.scorers ?? []))
let selectedCaseId = $state<string | undefined>(undefined)
let datasetDrawer: EvalDatasetDrawer | undefined = $state()
let runDialogOpen = $state(false)
let resumeRunDialog = $state(false)
/** The run the list has highlighted, so arrowing into the run page opens that one. Without it
* the arrow could only fall back to whichever run was opened last, which on a dialog just
* opened is none at all. */
let highlightedRunId = $state<string | undefined>(undefined)
/** How many cases each still-running run has finished, keyed by run id. Read from the flow
* executing the run: the list carries the case total, and counting the finished ones there
* would be a per-case query for every run listed. The flow already records it — one slot per
* case in `flow_jobs_success`, null until that case's iteration is over. */
let caseProgress = $state<Record<string, number>>({})
async function readCaseProgress() {
const workspace = ws
const live = experiments.filter((e) => e.running)
if (!workspace || live.length === 0) {
if (Object.keys(caseProgress).length > 0) caseProgress = {}
return
}
const read = await Promise.all(
live.map(async (e) => {
try {
const update = await JobService.getJobUpdates({
workspace,
id: e.run_job_id,
running: true,
noLogs: true
})
const cases = update.flow_status?.modules?.find((m) => m.id === CASES_MODULE_ID)
if (!cases) return undefined
return [
e.id,
(cases.flow_jobs_success ?? []).filter((s) => s != undefined).length
] as const
} catch {
// Left out of the map, so the row reads `0/total` until a later poll answers. A
// flow that cannot be read is already the list's problem to report, not this one's.
return undefined
}
})
)
caseProgress = Object.fromEntries(read.filter((e) => e !== undefined))
}
let experiment = $derived(experiments.find((e) => e.id === experimentId))
@@ -120,6 +172,7 @@
runsLoadError = false
try {
experiments = await listSubjectExperiments()
await readCaseProgress()
} catch (e) {
runsLoadError = true
sendUserToast(`Failed to load the runs: ${e}`, true)
@@ -183,6 +236,8 @@
// Switching datasets leaves the previous request in flight; only the newest may write, or a
// slow response for the dataset you just left replaces the one you are looking at.
let loadGeneration = 0
/** Which run the pane is opening; only the newest may clear `datasetLoading`. */
let openGeneration = 0
async function loadDataset(path: string | undefined): Promise<boolean> {
const generation = ++loadGeneration
@@ -345,6 +400,7 @@
await loadResults()
} else {
experiments = await listSubjectExperiments()
await readCaseProgress()
}
} finally {
refreshing = false
@@ -354,27 +410,46 @@
/** Opens a run, bringing its dataset with it and offering the run before it as the baseline.
* Reading the cells is left to the effect on the selection, so every way in opens one alike. */
async function openRun(id: string) {
// Against the dataset that is loaded, not the one that is selected: skipping on the selection
// alone would leave a run open over a dataset whose cases and scorers were never read.
const target = experiments.find((e) => e.id === id)
if (target && target.dataset !== dataset?.path) {
await useDataset(target.dataset)
// Only when the run itself changes: re-showing the one already open — arrowing back into it
// from the list — must keep whatever comparison the user picked.
if (id !== experimentId) {
const index = experiments.findIndex((e) => e.id === id)
// The run before it *of the same dataset*: the list spans datasets, and a run of another
// set of cases is not a baseline for this one.
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
}
const index = experiments.findIndex((e) => e.id === id)
// The run before it *of the same dataset*: the list spans datasets, and a run of another set
// of cases is not a baseline for this one.
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
experimentId = id
viewingRun = true
selectedCaseId = undefined
// Opened first, read second: the dataset is a request, and waiting on it here is a click
// that does nothing at all until the network answers. The page carries the wait instead.
//
// Against what is *selected* as well as what is loaded. `selectedDataset` moves the moment a
// read starts, so a load still in flight for another dataset shows up here: without that
// test, opening a run of the dataset already committed would skip `useDataset` entirely and
// leave the in-flight one free to commit its cases under this run.
const needsDataset =
!!target && (target.dataset !== dataset?.path || target.dataset !== selectedDataset)
datasetLoading = needsDataset
viewingRun = true
if (needsDataset) {
// Numbered like `loadDataset`'s own read, and for the same reason: opening a second run
// while the first is still loading leaves two `finally`s racing, and the loser clearing
// the flag would uncover the table with neither dataset in hand.
const generation = ++openGeneration
try {
await useDataset(target!.dataset)
} finally {
if (generation === openGeneration) datasetLoading = false
}
}
}
async function runAll(runSubject: EvalSubject, path: string): Promise<boolean> {
if (!ws || !path) return false
running = true
let id: string
try {
id = await AiEvalsService.runExperiment({
await AiEvalsService.runExperiment({
workspace: ws,
requestBody: { dataset: path, subject: runSubject }
})
@@ -386,9 +461,10 @@
// From here the run exists and is billing: what can still fail is reading it back, and
// saying "failed to run" to that invites a second, duplicate run.
try {
// Onto the list rather than into the run: a run that has just started has no answers and
// no scores, and the list already fills its row in as they land. Reading it is a click.
if (path !== dataset?.path) await useDataset(path)
await loadRuns()
await openRun(id)
} catch (e) {
sendUserToast(
`The run started but could not be read back: ${e}. Reload the runs list to see it.`,
@@ -407,8 +483,6 @@
/** The dataset is gone and every run of it with it: back to the list, on no dataset. */
async function datasetDeleted(path: string) {
// A run dialog waiting behind the drawer has nothing to come back to.
resumeRunDialog = false
if (selectedDataset === path) {
viewingRun = false
selectedCaseId = undefined
@@ -442,6 +516,13 @@
}
let selectedRow = $derived(displayRows.find((row) => row.case_id === selectedCaseId))
/** The case the side panel is showing. Held rather than read straight off the selection: the
* pane animates shut over a few hundred milliseconds, and the selection is gone on the first
* of them, which would empty the panel before it had finished closing. */
let openRow = $state<ExperimentRow | undefined>(undefined)
$effect(() => {
if (selectedRow) openRow = selectedRow
})
$effect(() => {
if (!ws) return
@@ -570,34 +651,52 @@
</script>
<div class="flex flex-col h-full min-h-0">
{#if loaded && loadError}
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
<span class="text-sm text-emphasis">Could not load evals</span>
<span class="text-xs text-secondary max-w-md">
The datasets or runs could not be read. Check your access to this agent and reload.
</span>
</div>
{:else}
<!-- Warmed: the run page is a table, a splitter and two selects, and building it on the first
click lands that work inside the transition — only the first navigation stutters, which
reads as the animation being unreliable rather than as a cost. -->
<PagedContent
warm
class="grow min-h-0"
current={!viewingRun || !loaded ? 'list' : 'run'}
onNavigate={(key) => {
// Right opens the run under the highlight, falling back to whichever was open before;
// left is the way back, the same as the breadcrumb.
if (key === 'run') {
// Both branches go through `openRun`: it is what brings the run's own dataset back,
// and the fallback run may be of a dataset the list has since moved off.
const id = highlightedRunId ?? experimentId
if (id) openRun(id)
} else if (key === 'list') {
viewingRun = false
selectedCaseId = undefined
}
}}
pages={[
{ key: 'list', content: listPage },
{ key: 'run', content: runPage }
]}
/>
{/if}
</div>
{#snippet listPage()}
<!-- Carried by the level rather than by the dialog: as the dialog's own `description` it
vanished the moment a run opened, shrinking the header and jolting the page under it. -->
<p class="text-xs text-secondary">
Each run answers a dataset of cases with this agent and scores the answers, so runs can be
compared.
</p>
<div class="flex flex-wrap items-end gap-2 py-2">
{#if viewingRun}
<Label label="Run" class="w-52 shrink">
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
</Label>
<Label label="Compare to" class="w-48 shrink">
<Select
items={experimentItems.filter((i) => i.value !== experimentId)}
bind:value={baselineId}
placeholder="No comparison"
clearable
disabled={experiments.length < 2}
class="text-xs"
/>
</Label>
{/if}
<div class="grow"></div>
{#if viewingRun && experiment?.run_job_id}
<a
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
target="_blank"
>
Open the job
<ExternalLink size={12} />
</a>
{/if}
{#if !viewingRun && loaded && datasets.length > 0}
{#if loaded && datasets.length > 0}
<Button
unifiedSize="md"
variant="default"
@@ -622,98 +721,115 @@
{/if}
{/if}
</div>
<div class="grow min-h-0 overflow-auto">
<EvalRunsList
{experiments}
{datasets}
{caseProgress}
{loaded}
active={!viewingRun}
{deployedHash}
{currentVersion}
onOpen={(e) => openRun(e.id)}
onHighlight={(id) => (highlightedRunId = id)}
onEditDataset={async (path) => {
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNew={() => (runDialogOpen = true)}
/>
</div>
{/snippet}
{#snippet runPage()}
<div class="flex flex-wrap items-end gap-2 py-2">
<Label label="Run" class="w-52 shrink">
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
</Label>
<Label label="Compare to" class="w-48 shrink">
<Select
items={experimentItems.filter((i) => i.value !== experimentId)}
bind:value={baselineId}
placeholder="No comparison"
clearable
disabled={experiments.length < 2}
class="text-xs"
/>
</Label>
<div class="grow"></div>
{#if experiment?.run_job_id}
<a
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
target="_blank"
>
Open the job
<ExternalLink size={12} />
</a>
{/if}
</div>
<div class="grow min-h-0">
<Splitpanes class="h-full">
<Pane size={selectedRow ? 60 : 100} minSize={35}>
<Splitpanes class="h-full splitter-hidden">
<Pane minSize={35}>
<div class="h-full overflow-auto">
{#if loaded && loadError}
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
<span class="text-sm text-emphasis">Could not load evals</span>
<span class="text-xs text-secondary max-w-md">
The datasets or runs could not be read. Check your access to this agent and reload.
</span>
</div>
{:else if loaded && datasets.length === 0}
<div class="h-full flex flex-col items-center justify-center gap-3 p-6 text-center">
<span class="text-sm text-emphasis">No dataset yet</span>
<span class="text-xs text-secondary max-w-md">
A dataset is the set of cases this agent is measured on. Runs are of a dataset, so
it is the first thing to make.
</span>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
onclick={() => datasetDrawer?.openDrawer('new')}
>
New dataset
</Button>
</div>
{:else if !viewingRun || !loaded}
<EvalRunsList
{experiments}
{datasets}
{loaded}
{deployedHash}
{currentVersion}
onOpen={(e) => openRun(e.id)}
onEditDataset={async (path) => {
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNew={() => (runDialogOpen = true)}
/>
{:else}
<DataTable size="sm" tableFixed rounded={!selectedRow}>
<colgroup>
<col style="width: 24%" />
<col style="width: 32%" />
{#each scorers as scorer (scorer.id)}
<col style="width: 9rem" />
{/each}
</colgroup>
<Head>
<tr>
<Cell head first>Case</Cell>
<Cell head last={scorers.length === 0}>Answer</Cell>
{#each scorers as scorer, index (scorer.id)}
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
{@const headline = columnHeadline(scorer, mean)}
<Cell head numeric last={index === scorers.length - 1}>
<!-- The second row keeps its height while there is nothing in it, so the table
does not move when the first score lands. -->
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
<span
class="flex items-center gap-1 min-w-0 max-w-full"
title={columnTitle(scorer, mean)}
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
{/if}
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
</span>
<span class="h-4 flex items-baseline gap-1.5 font-normal">
{#if headline}
<span class="tabular-nums text-emphasis font-semibold">
{headline.value}
<DataTable size="sm" tableFixed>
<colgroup>
<col style="width: 24%" />
<col style="width: 32%" />
{#each scorers as scorer (scorer.id)}
<col style="width: 9rem" />
{/each}
</colgroup>
<Head>
<tr>
<Cell head first>Case</Cell>
<Cell head last={scorers.length === 0}>Answer</Cell>
{#each scorers as scorer, index (scorer.id)}
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
{@const headline = columnHeadline(scorer, mean)}
<Cell head numeric last={index === scorers.length - 1}>
<!-- The second row keeps its height while there is nothing in it, so the table
does not move when the first score lands. -->
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
<span
class="flex items-center gap-1 min-w-0 max-w-full"
title={columnTitle(scorer, mean)}
>
{#if scorer.kind === 'agent'}
<Bot size={13} class="text-tertiary shrink-0" />
{:else}
<Code2 size={13} class="text-tertiary shrink-0" />
{/if}
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
</span>
<span class="h-4 flex items-baseline gap-1.5 font-normal">
{#if headline}
<span class="tabular-nums text-emphasis font-semibold">
{headline.value}
</span>
{#if headline.delta && headline.direction !== 0}
<span
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
>
{headline.delta}
</span>
{#if headline.delta && headline.direction !== 0}
<span
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
>
{headline.delta}
</span>
{/if}
{/if}
</span>
</div>
</Cell>
{/each}
{/if}
</span>
</div>
</Cell>
{/each}
</tr>
</Head>
<tbody class="divide-y">
{#if datasetLoading}
<!-- A run whose *results* are loading keeps its rows instead: `displayRows` already
names every case from the dataset in hand, which beats a skeleton. -->
<tr>
<td colspan={2 + scorers.length} class="p-3">
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
</td>
</tr>
</Head>
<tbody class="divide-y">
{:else}
{#each displayRows as row (row.case_id)}
{@const status = statusOf(row.status)}
<Row
@@ -813,52 +929,74 @@
{/each}
</Row>
{/each}
</tbody>
</DataTable>
{/if}
{/if}
</tbody>
</DataTable>
</div>
</Pane>
{#if selectedRow}
{@const openRow = selectedRow}
<Pane size={40} minSize={25}>
<!-- Animated rather than mounted and unmounted: the pane's own width is what moves, so the
cells table reflows alongside it instead of being shoved aside. -->
<AnimatedPane size={40} minSize={25} duration={180} opened={!!selectedRow}>
{#if openRow}
<div class="h-full overflow-auto flex flex-col">
<div class="flex items-start gap-2 px-3 py-2 border-b">
<span class="text-xs font-semibold text-emphasis break-words">
<!-- The title and the actions are different type sizes, so nothing lines them up on
its own. `leading-7` gives the title's first line the same box height as the
close button, and the actions centre in a row of that height beside it — so a
title that wraps grows downwards and leaves the row where it is. -->
<div class="flex items-start gap-2 px-3 py-2">
<span
class="text-xs font-semibold text-emphasis break-words leading-7 flex-1 min-w-0"
>
{openRow.input?.user_message ?? caseLabel(openRow)}
</span>
<div class="grow"></div>
{#if openRow.job_id}
<a
class="text-2xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 mt-0.5"
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
target="_blank"
>
Open the case job
<ExternalLink size={12} />
</a>
{/if}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: X }}
iconOnly
title="Close"
onclick={() => (selectedCaseId = undefined)}
/>
<div class="flex items-center gap-2 shrink-0 h-7">
{#if openRow.job_id}
<a
class="text-2xs text-accent hover:underline inline-flex items-center gap-1"
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
target="_blank"
>
Open the case job
<ExternalLink size={12} />
</a>
{/if}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: X }}
iconOnly
title="Close"
onclick={() => (selectedCaseId = undefined)}
/>
</div>
</div>
<div class="p-3 flex flex-col gap-4">
<!-- One card per thing the case has, rather than sections divided by rules: each
stands on its own and the panel reads as a stack. -->
<div class="px-3 pb-3 flex flex-col gap-2">
{#if openRow.expected != undefined && openRow.expected !== ''}
<Label label="Expected">
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
{typeof openRow.expected === 'string'
? openRow.expected
: JSON.stringify(openRow.expected, null, 2)}
</span>
</Label>
<div class="rounded-md border border-light overflow-hidden">
<div
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
>
<span class="text-2xs font-semibold text-secondary truncate">Expected</span>
</div>
<div class="p-2">
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
{typeof openRow.expected === 'string'
? openRow.expected
: JSON.stringify(openRow.expected, null, 2)}
</span>
</div>
</div>
{/if}
{#if scorers.length > 0 && openRow.scores.length > 0}
<Label label="Scores">
<div class="flex flex-col divide-y border rounded-md">
<div class="rounded-md border border-light overflow-hidden">
<div
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
>
<span class="text-2xs font-semibold text-secondary truncate">Scores</span>
</div>
<div class="flex flex-col divide-y divide-light">
{#each scorers as scorer (scorer.id)}
{@const cell = openRow.scores.find((s) => s.scorer_id === scorer.id)}
<div class="flex flex-col gap-1 px-2 py-1.5">
@@ -899,7 +1037,7 @@
</div>
{/each}
</div>
</Label>
</div>
{/if}
{#if experiment && (openRow.job_id || openRow.output != undefined)}
<div class="rounded-md border border-light overflow-hidden">
@@ -912,9 +1050,7 @@
</div>
<div class="p-2">
{#if openRow.output != undefined}
<div class="text-xs text-secondary break-words">
<GfmMarkdown md={openRow.output} noPadding />
</div>
<GfmMarkdown md={openRow.output} prose="sm" noPadding />
{:else if openRow.status === 'running'}
<span class="text-xs text-tertiary inline-flex items-center gap-1.5">
<Loader2 size={12} class="animate-spin text-blue-500" />
@@ -928,11 +1064,11 @@
{/if}
</div>
</div>
</Pane>
{/if}
{/if}
</AnimatedPane>
</Splitpanes>
</div>
</div>
{/snippet}
<EvalRunDialog
bind:open={runDialogOpen}
@@ -944,15 +1080,9 @@
{running}
onRun={runAll}
onEditDataset={async (path) => {
if (await useDataset(path)) {
resumeRunDialog = true
datasetDrawer?.openDrawer('edit')
}
}}
onNewDataset={() => {
resumeRunDialog = true
datasetDrawer?.openDrawer('new')
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
}}
onNewDataset={() => datasetDrawer?.openDrawer('new')}
/>
<EvalDatasetDrawer
@@ -968,11 +1098,14 @@
onDeleted={datasetDeleted}
onCasesChanged={casesChanged}
onScorersChanged={scorersChanged}
onClosed={() => {
if (!resumeRunDialog) return
resumeRunDialog = false
// On the dataset the drawer was just in: the dialog opens on the pane's own, which
// creating or editing one has already moved to it.
runDialogOpen = true
}}
/>
<style>
/* Direct child only, so the rule cannot reach a Splitpanes nested inside a page. Transparent
rather than `opacity: 0`: the gutter stays there to be dragged, it just stops drawing a line
between the cells and the case beside them. */
:global(.splitter-hidden > .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
}
</style>
@@ -1,26 +0,0 @@
/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame
rather than inherit it. */
.ag-theme-alpine .wm-multiline-cell-editor,
.ag-theme-alpine-dark .wm-multiline-cell-editor {
background-color: var(--ag-background-color);
}
.ag-theme-alpine .wm-multiline-cell-editor textarea,
.ag-theme-alpine-dark .wm-multiline-cell-editor textarea {
display: block;
box-sizing: border-box;
/* Horizontal only: the vertical padding is set by the editor, which knows the height of the row
it is replacing. `line-height` here is what it computes against. */
padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px);
border: 1px solid var(--ag-input-focus-border-color);
border-radius: 3px;
outline: none;
resize: none;
/* Past this it scrolls rather than growing. */
max-height: 40vh;
overflow-y: auto;
background-color: var(--ag-background-color);
color: var(--ag-foreground-color);
font: inherit;
line-height: 20px;
white-space: pre-wrap;
}
@@ -1,108 +0,0 @@
import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'
// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule
// added to it is one the next copy of it drops.
import './multilineCellEditor.css'
/** Kept in step with the `line-height` the stylesheet gives the textarea. */
const LINE_HEIGHT = 20
/**
* A text cell editor that starts the height of the cell and grows as lines are added, for columns
* holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels.
*
* Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so
* growing is only visible if the editor is allowed to paint outside it.
*/
export class MultilineCellEditor implements ICellEditorComp {
private eGui!: HTMLDivElement
private textarea!: HTMLTextAreaElement
private params!: ICellEditorParams
private wasEmpty = false
init(params: ICellEditorParams) {
this.params = params
this.eGui = document.createElement('div')
this.eGui.className = 'wm-multiline-cell-editor'
this.wasEmpty = params.value == undefined
this.textarea = document.createElement('textarea')
this.textarea.rows = 1
// A keystroke that opened the edit replaces the value, as it does in every other cell; F2
// and double-click keep it to be edited.
this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '')
this.textarea.style.width = `${params.column.getActualWidth() - 2}px`
// Padded so one line fills the cell it replaces and a second costs a line rather than a row.
// From the row rather than from `--ag-row-height`, which is the theme's figure and not
// necessarily this grid's.
const rowHeight = params.node.rowHeight ?? 28
const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2)
this.textarea.style.paddingTop = `${padding}px`
this.textarea.style.paddingBottom = `${padding}px`
this.textarea.addEventListener('input', () => this.resize())
this.textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Kept from whatever is around the grid: a grid in a drawer or a dialog is under a
// surface that closes on Escape, and leaving an edit is not asking to leave that.
e.preventDefault()
e.stopPropagation()
this.params.api.stopEditing(true)
return
}
if (e.key !== 'Enter' || e.isComposing) return
// Both branches keep the key from the grid, which ends the edit on Enter whether or not
// Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter
// ends the edit here instead.
e.stopPropagation()
if (!e.shiftKey) {
e.preventDefault()
this.params.stopEditing()
}
})
this.eGui.appendChild(this.textarea)
}
private resize() {
this.textarea.style.height = 'auto'
this.textarea.style.height = `${this.textarea.scrollHeight}px`
}
getGui() {
return this.eGui
}
afterGuiAttached() {
this.resize()
this.textarea.focus()
// At the end rather than selected: a selection is a keystroke away from erasing the cell.
const end = this.textarea.value.length
this.textarea.setSelectionRange(end, end)
}
getValue() {
// Nothing typed into a cell that held nothing is not an edit: returning '' here would write
// an empty string over a null, which the grid would see as a change and commit.
if (this.wasEmpty && this.textarea.value === '') return this.params.value
return this.textarea.value
}
isPopup() {
return true
}
getPopupPosition(): 'over' | 'under' {
return 'over'
}
}
/**
* What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as
* the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit
* under, so the editor cannot keep Shift+Enter for itself on its own.
*/
export const multilineCellColDef: Pick<ColDef, 'cellEditor' | 'suppressKeyboardEvent'> = {
cellEditor: MultilineCellEditor,
suppressKeyboardEvent: (p) =>
p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey
}
@@ -0,0 +1,173 @@
<!--
@component
Inline-editable prose. The multiline counterpart to `EditableInput`: idle renders
the whole text, wrapped, as a clickable button; clicking swaps to an auto-growing
`textarea`. `Enter` or `blur` commits via `onSave`, `Shift+Enter` adds a line,
`Escape` discards — except under `commitOnInput`, where every keystroke has
already been propagated and there is nothing left to discard.
Escape is stopped from propagating, which keeps a surrounding `Drawer` open. A `Modal`
is the exception: it takes both Enter and Escape on `window` in the capture phase and
stops them there, so neither reaches this. Inside one, pass `enterConfirms={false}` to
the `Modal` for Enter to commit here rather than confirm the dialog; Escape stays the
dialog's either way, so expect it to leave the edit and close the dialog together.
Use it where a cell or a field holds a sentence rather than a label — a prompt, a
description, an expected answer. For a one-line label, use `EditableInput`.
```svelte
<EditableTextarea
value={question}
placeholder="Question"
onSave={(v) => (question = v)}
textClass="text-xs font-normal"
/>
```
Like `EditableInput`, the value isn't bound: `onSave` fires whenever the draft
differs from the prior `value`, including `''` when cleared. Unlike it, the draft
goes back verbatim — whitespace a caller stored on purpose survives a focus and a
blur. The parent owns the canonical state.
-->
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
interface Props {
/** Current value shown in idle mode and pre-filled when entering edit mode. */
value: string
/** Shown when `value` is empty, in both idle and editing modes. */
placeholder?: string
/**
* Called when the user commits a changed value (Enter or blur, or every keystroke when
* {@link commitOnInput} is set). Fires with the draft exactly as typed, including `''` if the
* field was cleared. Not called on Escape, or when the draft matches the prior `value`.
*/
onSave?: (newValue: string) => void
/**
* Fire `onSave` on every keystroke instead of only on Enter/blur. Use when the parent
* autosaves, or when a control outside this one reads the value on click — the click blurs
* the textarea, and a commit-on-blur would land after the read. Escape no longer discards
* with this on: the live commits have already propagated.
*/
commitOnInput?: boolean
/** When false, renders as plain text and is not clickable. */
editable?: boolean
/** Textarea size in editing mode. Idle mode is text only and unaffected. */
size?: 'xs' | 'sm' | 'md' | 'lg'
/** Wrapper classes. Layout only — margin, width, alignment — not text styling. */
class?: string
/** Extra classes on the `<textarea>`. Background, border and shadow are reset over these. */
inputClass?: string
/**
* Text styling applied to *both* the idle text and the textarea, so the two render
* identically and clicking in does not shift the text.
*/
textClass?: string
}
let {
value,
placeholder = '',
onSave,
commitOnInput = false,
editable = true,
size = 'sm',
class: className = '',
inputClass = '',
textClass = ''
}: Props = $props()
let editing = $state(false)
let draft = $state('')
// `TextInput` is generic over its underlying element and defaults to `'input'`; this one renders
// a textarea, so the binding has to say so or it is typed as the wrong component.
let textInputComponent: TextInput<'textarea'> | undefined = $state(undefined)
function startEditing() {
if (!editable) return
editing = true
draft = value ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
/** Open the editor from outside — a row that was just added, say, so the caller does not have
* to click into it. */
export function edit() {
startEditing()
}
function save() {
// Re-entry guard: Enter calls `save()` and clears `editing`, which unmounts the textarea and
// synchronously fires its `blur` handler — also `save()`. Without this, `onSave` fires twice.
if (!editing) return
editing = false
// Verbatim, not trimmed: a caller's value may hold whitespace that means something, and
// merely focusing and blurring a field must never count as an edit.
if (draft !== (value ?? '')) {
onSave?.(draft)
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Kept from a surrounding drawer, which would otherwise close on it. A `Modal` takes
// Escape at `window` in the capture phase and is past us before this runs.
e.preventDefault()
e.stopPropagation()
editing = false
return
}
// Shift+Enter falls through to the textarea's own newline; plain Enter commits, which is
// what makes this usable in a table where Enter means "done with this cell".
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
save()
}
}
function handleLiveInput(e: Event) {
const next = (e.currentTarget as HTMLTextAreaElement).value
if (next !== (value ?? '')) onSave?.(next)
}
</script>
{#if editing}
<!-- Every override below exists so the two states are the same size with the text in the same
place, and clicking in moves nothing: `!min-h-0` (TextInput floors it at 28px), `!block` (an
inline-block textarea baseline-aligns and the idle button does not), and never `!h-auto` —
`use:autosize` grows the field with an inline `style.height` an `!important` class beats. -->
<TextInput
bind:this={textInputComponent}
bind:value={draft}
{size}
underlyingInputEl="textarea"
autosizeParams={{ minHeight: 0 }}
class="!block !bg-transparent !border-0 !shadow-none !m-0 !min-w-0 !min-h-0 !py-0 {textClass} {inputClass} {className}"
inputProps={{
placeholder,
rows: 1,
onblur: save,
onkeydown: handleKeydown,
oninput: commitOnInput ? handleLiveInput : undefined,
spellcheck: false,
style: 'padding: 2px !important; resize: none'
}}
/>
{:else}
<!-- Padding 2px over, 4px under: the same 22px box as the textarea *and* the same 2px above the
first line. An even 3px gets the box right and leaves the text a pixel low. -->
<button
type="button"
onclick={startEditing}
disabled={!editable}
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
class="w-full text-left whitespace-pre-wrap break-words rounded px-0.5 pt-[2px] pb-[4px] {editable
? 'cursor-text hover:bg-surface-hover'
: 'cursor-default'} {textClass} {className}"
>
{value || placeholder}
</button>
{/if}
@@ -8,6 +8,7 @@
import { onMount, createEventDispatcher, setContext, untrack } from 'svelte'
import { BROWSER } from 'esm-env'
import Disposable from './Disposable.svelte'
import { setTopmostSurface } from '$lib/components/common/overlayHost.svelte'
import ConditionalPortal from './ConditionalPortal.svelte'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
@@ -51,6 +52,11 @@
let disposable: Disposable | undefined = $state(undefined)
// A drawer stacks like a dialog does, so content inside it gets the same answer about whether
// its keys are meant for it. Without this, a drawer opened over a dialog would inherit the
// dialog's answer — false, because the drawer itself is now on top — and go deaf.
setTopmostSurface(() => disposable?.isTopmost() ?? true)
let reducedMotion = useReducedMotion()
let duration = $derived(reducedMotion.val ? 0 : _duration)
let durationMs = $derived(duration * 1000)
@@ -10,6 +10,16 @@
label: string
icon?: any
onClick: () => void
/**
* `default` unless the surface has no other live call to action. Accent is for the case
* where this button is the only thing to press — a form whose submit is disabled until
* this is done, say — so it is not competing with one.
*/
variant?: 'default' | 'accent'
/** Same write lock the surface's other controls take. An empty state is still a live
* control: without this it stays clickable while a request that has already read the
* empty list is in flight, and whatever it adds is discarded when that request lands. */
disabled?: boolean
aiId?: string
aiDescription?: string
}
@@ -32,11 +42,13 @@
{/if}
</div>
{#if action}
<!-- `default`, not `accent`: the page header already carries the accent CTA for
this same action, and one accent per view is the rule. -->
<!-- `default` by default: the page header usually carries the accent CTA for this same
action, and one accent per view is the rule. A caller whose other CTA is disabled until
this is done opts into `accent`. -->
<Button
unifiedSize="md"
variant="default"
variant={action.variant ?? 'default'}
disabled={action.disabled}
startIcon={action.icon ? { icon: action.icon } : undefined}
onClick={action.onClick}
aiId={action.aiId}
@@ -11,7 +11,11 @@
<script lang="ts">
import { createBubbler, stopPropagation } from 'svelte/legacy'
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
import { getOverlayHost, overlayHostActive } from '$lib/components/common/overlayHost.svelte'
import {
getOverlayHost,
overlayHostActive,
setTopmostSurface
} from '$lib/components/common/overlayHost.svelte'
const bubble = createBubbler()
import { createEventDispatcher, untrack } from 'svelte'
@@ -38,6 +42,18 @@
/** A line under the title saying what the dialog is for; in the header so it does not
* scroll away with the body. */
description?: string
/** The body holds pages that are laid over each other rather than stacked, so the header
* keeps its own height and only the pages move. Requires `fillHeight`: pages are absolutely
* positioned and need a definite height to fill. Pair with `PagedContent`, which does the
* laying over; this only makes room for it. */
paginated?: boolean
/**
* Whether Enter confirms the dialog. On by default, which is right for a form. Turn it off
* where the body is a surface with its own meaning for Enter: the handler runs at `window`
* in the capture phase and stops propagation, so while it is on nothing inside the dialog
* can see the key at all.
*/
enterConfirms?: boolean
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
* the dialog otherwise hugs its content, and percentage heights inside it do not
@@ -65,6 +81,8 @@
trail = undefined,
description = undefined,
fillHeight = false,
paginated = false,
enterConfirms = true,
minZIndex: minZIndexProp = undefined,
titleBadge,
settings,
@@ -95,6 +113,10 @@
// 1200 to stack above.
const minZIndex = $derived(minZIndexProp ?? (chatState.size > 0 ? zIndexes.aiChat + 1 : 0))
// So content in the body can tell a key meant for this dialog from one meant for whatever was
// opened over it. Read lazily: `disposable` is bound after this runs.
setTopmostSurface(() => disposable?.isTopmost() ?? true)
// Both `bind:open` and this $effect are needed: bind:open syncs the
// boolean, while the effect calls openDrawer/closeDrawer to register
// the disposable in the stacking system (same pattern as Drawer.svelte).
@@ -114,6 +136,7 @@
if (open) {
switch (event.key) {
case 'Enter':
if (!enterConfirms) break
event.stopPropagation()
event.preventDefault()
dispatch('confirmed')
@@ -231,7 +254,9 @@
{@render titleBadge?.()}
{/if}
{#if i > 0}
<ChevronRight size={18} class="text-tertiary shrink-0" />
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
<ChevronRight size={18} class="text-tertiary shrink-0" />
</span>
{/if}
{#if i === 0}
<!-- flex: an inline child holding an icon sits on the baseline and adds
@@ -250,7 +275,11 @@
{:else if segment.onclick}
{@render crumb(segment, i === crumbs.length - 2)}
{:else}
<span class="text-emphasis truncate" aria-current="page">
<span
class="text-emphasis truncate"
aria-current="page"
in:fade={{ duration: 150 }}
>
{segment.label}
</span>
{/if}
@@ -276,7 +305,13 @@
<p class="mt-1 text-xs text-secondary">{description}</p>
{/if}
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
<!-- `mt-1` when paginated: a page carries its own description as its first line,
and it belongs where the dialog's own sat, right under the title. -->
<div
class="{paginated ? 'mt-1' : 'mt-4'} text-sm text-primary {fillHeight
? 'flex-1 min-h-0'
: ''}"
>
{@render children_render?.()}
</div>
</div>
@@ -0,0 +1,202 @@
<script lang="ts" module>
import type { Snippet } from 'svelte'
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
* and every other waits off the side it is listed on, so a deeper page arrives from the right
* and the way back arrives from the left without anyone naming a direction. */
export type ModalPage = { key: string; content: Snippet }
</script>
<script lang="ts">
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
let {
pages,
current,
onNavigate,
warm = false,
class: c = ''
}: {
pages: ModalPage[]
/** The `key` of the page on screen. */
current: string
/**
* Go to a page. Given, the component answers the left and right arrow keys, stepping between
* pages the way the surface's own controls do. Escape is deliberately not taken: the dialog
* around this owns it, and a page component that swallowed it would stop the dialog from
* closing. Without this prop the pages are still navigable, just not from the keyboard —
* the caller owns `current` either way.
*/
onNavigate?: (key: string) => void
/**
* Build every page once this one has settled, rather than each on its first visit. Turn it
* on where a page is heavy enough that building it costs a frame: otherwise that build
* lands on the first click, inside the transition, and only the first navigation stutters.
* The cost moves to a moment when nothing is animating and nobody is waiting.
*/
warm?: boolean
class?: string
} = $props()
/** A page is mounted the first time it is opened and never unmounted again, which is the whole
* point of this over an `{#if}`: a page keeps its state, its loaded data and its scroll
* position while another is on screen, and returning to it costs nothing. */
let visited = $state<string[]>([])
$effect(() => {
if (!visited.includes(current)) visited = [...visited, current]
})
/** Whether the rest of the pages have been built. Deferred a frame past mount rather than done
* outright: the page being opened *now* is what the user is waiting on, and building its
* neighbours in the same frame would make opening the dialog pay for them. */
let warmed = $state(false)
$effect(() => {
if (!warm || warmed) return
const frame = requestAnimationFrame(() => (warmed = true))
return () => cancelAnimationFrame(frame)
})
let box: HTMLDivElement | undefined = $state()
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
// still the one on top, rather than under a drawer or a dialog opened since.
const hostActive = overlayHostActive()
const onTop = topmostSurface()
const listening = () => hostActive() && onTop()
/** Whether the keyboard is ours to answer.
*
* Deliberately not "focus is inside the pages". Navigating leaves focus wherever the page that
* was left put it — the body, once `inert` lands on it, or a button in the surrounding dialog
* chrome — so a containment test answers the first press and refuses every one after it. What
* is actually being asked is whether the key is meant for something else, and only a control
* that reads arrows itself qualifies. */
function ownsKeyboard(target: EventTarget | null): boolean {
if (!box || !box.isConnected) return false
const el = target as HTMLElement | null
return !el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')
}
function onKeydown(event: KeyboardEvent) {
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
if (!ownsKeyboard(event.target)) return
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (step === 0) return
const next = pages[index + step]
if (!next) return
event.preventDefault()
onNavigate(next.key)
}
/** Whether focus is inside the pages right now. Tracked as focus moves, not read when the page
* changes: a surrounding `Modal` handles Escape at `window` in the capture phase and steps a
* level back from there, and Svelte flushes this component's effects inside that handler —
* before a keydown listener of ours would ever run. Only a value already settled survives that.
* Plain state, not `$state`: the effect below is keyed on the page, and reading this must not
* key it too. */
let focusInside = false
$effect(() => {
if (!box) return
const enter = () => (focusInside = true)
const leave = (event: FocusEvent) => {
focusInside = !!event.relatedTarget && !!box?.contains(event.relatedTarget as Node)
}
box.addEventListener('focusin', enter)
box.addEventListener('focusout', leave)
return () => {
box?.removeEventListener('focusin', enter)
box?.removeEventListener('focusout', leave)
}
})
/** Focus follows the page, or a keyboard user is dropped out of the dialog: `inert` on the page
* being left resets focus to the body, and so does the trail's back button, which is removed
* as the level it returns from closes. Focus is taken when it was in the pages, or when it has
* ended up nowhere — never off a control the user is actually on, such as dialog chrome that
* outlives the navigation. */
let previous = current
$effect(() => {
const next = current
if (next === previous) return
const wasInside = focusInside
previous = next
// Claimed over a few frames rather than one: the browser runs its own fixup for the page
// that just became inert, and it lands after this on some paths.
let tries = 3
let frame = 0
const claim = () => {
const page = box?.querySelector<HTMLElement>('.paged-content-page:not(.is-hidden)')
if (!page) return
if ((wasInside && tries === 3) || document.activeElement === document.body) {
page.focus({ preventScroll: true })
}
if (--tries > 0) frame = requestAnimationFrame(claim)
}
frame = requestAnimationFrame(claim)
return () => cancelAnimationFrame(frame)
})
/** How far a page travels, as a share of the box. Deliberately a fraction and not the whole
* width: at 100% a page has left the box before its opacity has gone anywhere, so the fade is
* spent off-screen and all that is left to see is a slide. */
const TRAVEL_PERCENT = 24
let index = $derived(
Math.max(
pages.findIndex((p) => p.key === current),
0
)
)
</script>
<!-- The clipper. `overflow` clips descendants and not an element's own transform, so the box that
hides a page off the side can never be the page that travels. -->
<svelte:window onkeydown={onKeydown} />
<div bind:this={box} class="relative overflow-hidden {c}">
{#each pages as page, i (page.key)}
{#if visited.includes(page.key) || warmed}
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
height as one replaces another. That needs a definite height from the caller.
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
tab order and every screen reader still walk through it. -->
<!-- `tabindex="-1"`: focusable programmatically, never a tab stop of its own. -->
<div
tabindex="-1"
class="paged-content-page absolute inset-0 flex flex-col"
class:is-hidden={i !== index}
class:opacity-0={i !== index}
class:pointer-events-none={i !== index}
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
inert={i !== index}
>
{@render page.content()}
</div>
{/if}
{/each}
</div>
<style>
/* Arriving: slides in and fades up. A transition reads the duration of the state it is moving
*to*, which is what lets the two directions differ here. */
.paged-content-page {
transition-property: transform, opacity;
transition-duration: 130ms, 90ms;
transition-timing-function: ease-out;
}
/* Leaving: gone at once. Both pages share the box while one replaces the other, so an outgoing
page is painted over the incoming one for as long as it still has any opacity — any fade at
all is a ghost laid over the page you are trying to look at. It still travels, which costs
nothing to watch because it is already invisible. */
.paged-content-page.is-hidden {
transition-duration: 130ms, 0s;
}
@media (prefers-reduced-motion: reduce) {
.paged-content-page {
transition: none;
}
}
</style>
@@ -72,3 +72,23 @@ export function overlayHostActive(): () => boolean {
const host = getOverlayHost()
return () => host?.active() ?? true
}
const TOPMOST_SURFACE_KEY = 'topmostSurface'
/**
* Declare whether the surface enclosing this subtree is the one on top. Set by whatever owns the
* stacking a dialog, a drawer so content inside it can tell a key meant for itself from one
* meant for something opened over it.
*/
export function setTopmostSurface(isTopmost: () => boolean) {
setContext(TOPMOST_SURFACE_KEY, isTopmost)
}
/**
* Whether the enclosing surface is on top. True when nothing declared otherwise, so content that
* is not inside such a surface is not silently made deaf.
*/
export function topmostSurface(): () => boolean {
const isTopmost = getContext<(() => boolean) | undefined>(TOPMOST_SURFACE_KEY)
return () => isTopmost?.() ?? true
}
@@ -109,13 +109,13 @@
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
kind="app"
{keyboardSelected}
{marked}
path={(app as any).draft_path ?? app.path}
summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={!app.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -132,13 +132,13 @@
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{keyboardSelected}
{marked}
path={flow.draft_path ?? flow.path}
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -35,13 +35,13 @@
<Row
href="{base}/apps_raw/get/{app.path}"
kind="raw_app"
{keyboardSelected}
{marked}
path={app.path}
summary={app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={true}
{depth}
{keyboardSelected}
>
{#snippet badges()}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
@@ -13,6 +13,8 @@
interface Props {
marked: string | undefined
selected?: boolean
/** Highlighted by the list's keyboard arrow-navigation (distinct from `selected`,
* which is the checkbox multi-select state). Scrolls itself into view. */
keyboardSelected?: boolean
disabled?: boolean
canFavorite?: boolean
@@ -147,6 +147,7 @@
? `${base}/scripts/edit/${script.path}`
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
{keyboardSelected}
{marked}
path={script.draft_path ?? script.path}
summary={script.is_draft
@@ -156,7 +157,6 @@
workspaceId={$workspaceStore ?? ''}
canFavorite={!script.draft_only}
{depth}
{keyboardSelected}
{rowSelection}
>
{#snippet badges()}
@@ -187,7 +187,9 @@
<Badge small color="yellow" baseClass="border">CI test</Badge>
</Popover>
{/if}
{#if script.kind !== 'script'}
<!-- Guard on a non-empty kind: a draft-only script can carry an empty `kind`, which
still isn't 'script' and would render an empty blue badge. -->
{#if script.kind && script.kind !== 'script'}
<Badge color="blue" baseClass="border"
>{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)}</Badge
>
@@ -51,20 +51,26 @@
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
)
// A spent free grant is not an unconfigured workspace: AIChatDisplay already shows an
// in-thread banner naming the real cause and linking to the key settings, so the generic
// "enable Windmill AI" line would both duplicate it and misstate why the chat is off.
const freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
const disabledMessage = $derived(
forceDisabled
? forceDisabledMessage
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -15,6 +15,7 @@
Folder,
Hand,
HistoryIcon,
KeyRound,
MousePointer2,
Plug,
Plus,
@@ -59,9 +60,22 @@
readDroppedEntries
} from './files/fsAccess'
import { sendUserToast } from '$lib/toast'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { copilotInfo } from '$lib/aiStore'
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const aiChatManager = getAiChatManager()
// The user spent their one-time free Windmill AI grant: there is no model left to send
// to, so say so in the thread itself rather than only failing on send.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
// Still on the free grant: keep how much is left in view right above the composer, so
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
let freeTier = $derived($copilotInfo.freeTier)
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
// One row per autonomy posture, in picker order, so adding one touches only this
// table. `isAvailable` hides the postures that would do nothing in the current AI
// mode, which is why the picker can be shorter than this list.
@@ -604,6 +618,44 @@
)
</script>
{#snippet freeTierExhaustedBanner()}
<div class="my-2">
<Alert type="info" size="xs" title="Free Windmill AI used up">
<div class="flex flex-col items-start gap-2">
<span>
You have used all of your free Windmill AI tokens. Add your own API key to keep using AI.
</span>
<Button
unifiedSize="2xs"
variant="accent"
startIcon={{ icon: KeyRound }}
href="{base}/workspace_settings?tab=ai"
>
Add your own API key
</Button>
</div>
</Alert>
</div>
{/snippet}
{#snippet freeTierUsageBanner()}
<div
class="my-1 flex items-center justify-between gap-2 rounded-md border bg-surface-secondary px-2 py-1"
>
<span class="text-xs text-secondary tabular-nums">
{freeTierUsedPct}% of your free Windmill AI used
</span>
<Button
unifiedSize="2xs"
variant="default"
startIcon={{ icon: KeyRound }}
href="{base}/workspace_settings?tab=ai"
>
Configure your API key
</Button>
</div>
{/snippet}
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div
@@ -762,6 +814,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
script editor to modify selected lines.</span
>
{/if}
{#if freeTierExhausted}
<div class={wideLayout ? 'w-full max-w-3xl mx-auto px-7' : 'w-full max-w-2xl mx-auto px-3'}>
{@render freeTierExhaustedBanner()}
</div>
{/if}
{/if}
{#if messages.length > 0}
@@ -790,6 +847,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
isLast={messageIndex === messages.length - 1 && !remoteUserEcho}
/>
{/each}
{#if freeTierExhausted}
{@render freeTierExhaustedBanner()}
{/if}
<!-- Same trailing space the last message reserves: the indicator below
is sticky and pulled up over it, so a bubble without it is covered. -->
{@render remoteUserEchoBubble('mb-12')}
@@ -892,6 +952,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if inputPreface}
{@render inputPreface()}
{/if}
{#if showFreeTierUsage}
{@render freeTierUsageBanner()}
{/if}
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
@@ -86,6 +86,8 @@ import { untrack } from 'svelte'
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
@@ -366,6 +368,25 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
return appendWebSearchErrorHint(message, webSearchUnavailable)
}
/** Re-fetch copilotInfo after a free-tier turn so the usage banner tracks spend live and the
* exhausting turn flips `freeTier.exhausted`; otherwise these update only on the next workspace
* load. Scoped to a live (non-exhausted) free tier so configured-key users pay no extra request. */
async function refreshFreeTierUsage(workspace: string | undefined) {
if (!workspace) return
// copilotInfo is a singleton shared across sessions: a warm session finishing after a
// workspace switch must not loadCopilot for its now-background workspace. Gate on the
// most-recently-*requested* workspace (set synchronously) so a refresh can't win the
// monotonic token over a newer load still in flight.
if (get(copilotWorkspaceRequested) !== workspace) return
const info = get(copilotInfo)
if (!info.freeTier || info.freeTier.exhausted) return
try {
await loadCopilot(workspace)
} catch (err) {
console.error('Failed to refresh free-tier usage', err)
}
}
/** A message queued while a turn streams: the draft lanes and the pinned
* context snapshot always move together so a flush can't drop one. */
type QueuedEntry = {
@@ -3917,6 +3938,9 @@ export class AIChatManager {
// releases the loop; it never discards uncommitted text.
this.replyReveal.reset()
this.reasoningReveal.reset()
// Refresh the free-tier usage meter after every turn (success or error), and
// let the turn that exhausts the grant flip to the exhausted state live.
void refreshFreeTierUsage(this.operatingWorkspace)
}
// Flush the queued message. Send it after a cleanly committed turn OR a
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
@@ -46,6 +46,13 @@
)
let models = $derived($copilotInfo.aiModels)
// Free tier: the workspace has no key of its own and is spending Windmill's one-time
// grant. Label it so the user knows whose budget this is, and warn before it runs out
// rather than letting the grant die mid-task.
let freeTier = $derived($copilotInfo.freeTier)
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
@@ -312,6 +319,13 @@
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
@@ -3,7 +3,7 @@
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
import { getAiChatManager } from './aiChatManagerContext'
import { AIMode } from './AIChatManager.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import UsageMeter from './UsageMeter.svelte'
import { formatTokenCount } from './tokenUsage'
const aiChatManager = getAiChatManager()
@@ -49,25 +49,11 @@
</script>
{#if visible}
<Tooltip small placement="top">
<!-- Only a meter when a model is configured: it's a 0100% reading against the
window compaction enforces (known or assumed). With no model there's no max
to measure against, so it's a plain labeled indicator (the bar is
decorative/full and the token count lives in the tooltip). -->
<div
class="flex items-center h-5"
aria-label="Context window usage"
role={fillPct !== undefined ? 'meter' : undefined}
aria-valuenow={fillPct}
aria-valuemin={fillPct !== undefined ? 0 : undefined}
aria-valuemax={fillPct !== undefined ? 100 : undefined}
>
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
></div>
</div>
</div>
{#snippet text()}
<!-- Only a meter when we know the window: it's a 0100% reading. With an unknown
window there's no max to measure against, so it's a plain labeled indicator
(the bar is decorative/full and the token count lives in the tooltip). -->
<UsageMeter {fillPct} {fillClass} ariaLabel="Context window usage">
{#snippet tooltip()}
<div class="text-xs whitespace-nowrap">
<p class="font-semibold">Context usage</p>
<p class="mt-1 tabular-nums">
@@ -85,5 +71,5 @@
{/if}
</div>
{/snippet}
</Tooltip>
</UsageMeter>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
// Presentational gauge used by the context-usage indicator: a thin bar in a tooltip.
// The owner computes the fill and supplies the tooltip content.
let {
fillPct,
fillClass,
ariaLabel,
tooltip
}: {
// 0100. Undefined means "no known max": the bar is decorative (full) and carries
// no meter role.
fillPct?: number
fillClass: string
ariaLabel: string
tooltip: Snippet
} = $props()
</script>
<Tooltip small placement="top">
<div
class="flex items-center h-5"
aria-label={ariaLabel}
role={fillPct !== undefined ? 'meter' : undefined}
aria-valuenow={fillPct}
aria-valuemin={fillPct !== undefined ? 0 : undefined}
aria-valuemax={fillPct !== undefined ? 100 : undefined}
>
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
></div>
</div>
</div>
{#snippet text()}
{@render tooltip()}
{/snippet}
</Tooltip>
@@ -1,6 +1,14 @@
import { WorkspaceService } from '$lib/gen'
import { copilotWorkspace, setCopilotInfo } from '$lib/aiStore'
import { workspaceAIClients } from './lib'
import { writable } from 'svelte/store'
// The workspace of the most recent loadCopilot *request*, set synchronously before the
// await — as opposed to `copilotWorkspace`, which only updates once a load resolves. A
// background refresh (e.g. free-tier usage) compares against this so it can't supersede an
// in-flight load for a newer workspace (which would otherwise win the token and restore
// stale state).
export const copilotWorkspaceRequested = writable<string | undefined>(undefined)
// Lives here, not in $lib/aiStore, purely so that module needs no import of the AI
// client — it is the one thing that wanted both. Moving it back recreates the
@@ -20,6 +28,7 @@ let loadCopilotToken = 0
let inFlight: { workspace: string; promise: Promise<void> } | undefined
export function loadCopilot(workspace: string): Promise<void> {
copilotWorkspaceRequested.set(workspace)
if (inFlight?.workspace === workspace) {
return inFlight.promise
}
@@ -0,0 +1,255 @@
<script lang="ts" module>
/** Example prompts: the short `label` is shown as a clickable tag under the chat,
* the `prompt` is what gets typed out as the placeholder / dropped into the input. */
export const homeAIExamples: { label: string; prompt: string }[] = [
{
label: 'Sync Salesforce',
prompt: 'Sync new Salesforce leads into a postgres table every hour'
},
{
label: 'Ban Discord users',
prompt:
'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them'
},
{
label: 'Weekly Slack report',
prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday'
}
]
</script>
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { Badge } from '../common'
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { HOME_SHOW_HUB } from '$lib/consts'
import { base } from '$lib/base'
import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte'
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import { USER_SETTINGS_HASH } from '../sidebar/settings'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
let value = $state('')
let placeholder = $state('')
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
// config, so the home chat loads it for the current workspace itself.
$effect(() => {
if ($workspaceStore) {
loadCopilot($workspaceStore)
}
})
// Whether the copilot config has actually loaded for the current workspace.
let configLoaded = $derived($copilotWorkspace === $workspaceStore)
// No usable model (no provider configured, or AI disabled): the composer is blurred and an
// overlay explains why and links to the fix. Static, not hover-gated, so keyboard and touch
// users see it too. Gate on `configLoaded` so the initial (unloaded) state doesn't flash the
// overlay while a provider is in fact configured.
let disabled = $derived(configLoaded && !$copilotInfo.enabled)
// Submission is stricter than the overlay: block it until the config is loaded AND
// enabled. Submitting during the unknown-config window hands the prompt to a session
// that only sends once `copilotInfo.enabled` flips true — on an unconfigured/disabled
// workspace that never happens and the prompt is silently lost.
let canSend = $derived(configLoaded && $copilotInfo.enabled)
// Applied to the AI-specific parts only (title, input, example tags) when disabled. The
// CLI/MCP and Hub buttons are unrelated to AI and stay sharp and clickable.
let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '')
// Disabled because the user spent their free Windmill AI grant, not because AI was never
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
let showComposer = $derived(prefersSessionHandoff($userStore?.operator))
let starting = $state(false)
async function start() {
if (!canSend || starting || !value.trim()) return
starting = true
try {
await startSessionWithPrompt(value, { autoSend: true })
} finally {
starting = false
}
}
// Enter starts the session; Shift+Enter keeps inserting a newline.
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
start()
}
}
const prompts = homeAIExamples.map((e) => e.prompt)
const TYPE_MS = 45
const DELETE_MS = 25
const HOLD_MS = 1800
const PAUSE_MS = 400
// Typewriter effect: type a prompt out, hold, delete, then advance to the next. Only while the
// composer is shown — otherwise (operators) it would loop forever driving an unrendered input.
$effect(() => {
if (!showComposer) return
let promptIndex = 0
let charIndex = 0
let deleting = false
let timer: ReturnType<typeof setTimeout>
function tick() {
const current = prompts[promptIndex]
if (!deleting) {
charIndex++
placeholder = current.slice(0, charIndex)
if (charIndex >= current.length) {
deleting = true
timer = setTimeout(tick, HOLD_MS)
return
}
timer = setTimeout(tick, TYPE_MS)
} else {
charIndex--
placeholder = current.slice(0, charIndex)
if (charIndex <= 0) {
deleting = false
promptIndex = (promptIndex + 1) % prompts.length
timer = setTimeout(tick, PAUSE_MS)
return
}
timer = setTimeout(tick, DELETE_MS)
}
}
timer = setTimeout(tick, TYPE_MS)
return () => clearTimeout(timer)
})
</script>
<div class="w-full flex justify-center">
<div class="max-w-[40rem] grow relative group">
{#if showComposer}
<div class={blurClass} inert={disabled}>
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">Build with AI</p>
<Badge color="blue" small>Beta</Badge>
</div>
<!-- anchors the send button / model settings to the input, not to the whole
block — the row below would otherwise push them down -->
<div class="relative">
<TextInput
bind:value
class="resize-none px-4 py-3 pb-9 shadow-sm border-accent"
underlyingInputEl="textarea"
inputProps={{ rows: 4, placeholder, onkeydown: onKeydown }}
/>
<Button
endIcon={starting ? {} : { icon: ArrowUp }}
wrapperClasses="absolute right-2 bottom-3.5"
variant={value.trim() ? 'accent' : 'subtle'}
iconOnly
loading={starting}
disabled={!value.trim() || starting || !canSend}
onclick={start}
></Button>
<div class="absolute left-3 bottom-4 flex items-center gap-1.5 px-0.5">
<AIChatModelSettings />
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between gap-2">
{#if showComposer}
<div class="flex flex-row flex-wrap items-center gap-1.5 {blurClass}" inert={disabled}>
{#each homeAIExamples as example (example.label)}
<Button
variant="default"
unifiedSize="xs"
btnClasses="!rounded-full !text-2xs !text-hint"
onClick={() => (value = example.prompt)}
>
{example.label}
</Button>
{/each}
</div>
{:else}
<div></div>
{/if}
<!-- Not AI-related, so shown even to operators / when the composer is hidden: kept out of
the blurred subtree and above the disabled overlay so it stays sharp and clickable. -->
<div class="relative z-20 flex flex-row items-center gap-1">
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: PlugZap }}
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{#if !$userStore?.operator && HOME_SHOW_HUB}
<Button
variant="subtle"
unifiedSize="xs"
btnClasses="!text-2xs !text-hint"
startIcon={{ icon: Globe2 }}
endIcon={{ icon: ExternalLink }}
href={$hubBaseUrlStore}
target="_blank"
>
Hub
</Button>
{/if}
</div>
</div>
{#if showComposer && disabled}
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
>
<p class="text-sm text-secondary">
{#if $aiUserDisabled}
Windmill AI is disabled in your account settings
{:else if freeTierExhausted}
You have used all of your free Windmill AI tokens
{:else}
No AI provider is configured
{/if}
</p>
{#if $aiUserDisabled}
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Settings }}
href={USER_SETTINGS_HASH}
>
Open account settings
</Button>
{:else}
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
href="{base}/workspace_settings?tab=ai"
>
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
</Button>
{/if}
</div>
{/if}
</div>
</div>
<HomeConnectDrawer bind:this={homeConnectDrawer} />
+290 -239
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
import { Badge, Button, Skeleton } from '$lib/components/common'
import { Button, Skeleton } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import {
AssetService,
@@ -24,51 +24,176 @@
ChevronsDownUp,
ChevronsUpDown,
Code2,
LayoutDashboard,
ListFilterPlus,
SearchCode,
Tag
LayoutDashboard
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import ContentSearchInner from '$lib/components/ContentSearchInner.svelte'
import type { Item as MenuItem } from '$lib/utils'
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
import SearchItems from '../SearchItems.svelte'
import ListFilters from './ListFilters.svelte'
import FilterSearchbar, {
useUrlSyncedFilterInstance,
type FilterSchemaRec
} from '$lib/components/FilterSearchbar.svelte'
import NoItemFound from './NoItemFound.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './FlowIcon.svelte'
import { canWrite, getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import { setQuery } from '$lib/navigation'
import Drawer from '../common/drawer/Drawer.svelte'
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Item from './Item.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import { effectivePath, type ItemType } from './treeViewUtils'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { getContext, tick, untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { NetworkIcon } from 'lucide-svelte'
import { base } from '$lib/base'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
interface Props {
filter?: string
subtab?: 'flow' | 'script' | 'app'
showEditButtons?: boolean
}
let {
filter = $bindable(''),
subtab = $bindable('script'),
showEditButtons = true
}: Props = $props()
let { subtab = $bindable('script'), showEditButtons = true }: Props = $props()
// Which user-folder scoping toggle (if any) this role gets. Declared before the
// FilterSearchbar schema and the derived filters that both read it.
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
$userStore?.non_member
? 'only f/*'
: $userStore?.is_admin || $userStore?.is_super_admin
? 'u/username and f/*'
: undefined
)
// FilterSearchbar schema — `_default_` is the free-text search; the rest mirror the
// boolean/kind list filters. Owner and label scoping are offered as searchbar presets
// (searchPresets) and resolve server-side (path_start / label) rather than filtering
// client-side. `content` is a distinct mode: it swaps the list for the client-side
// content-match view below (usable on any instance, not EE-gated).
let searchFilterSchema = $derived({
_default_: { type: 'string' as const, hidden: true },
content: {
type: 'string' as const,
label: 'Content',
description: 'Search across item contents'
},
// Owner (u/<user> or f/<folder>) and label are offered as presets built from what the
// list actually holds (see searchPresets); they drive the same server path-scope / label
// filter the old on-page chips did.
owner: { type: 'string' as const, label: 'Owner' },
label: { type: 'string' as const, label: 'Label' },
kind: {
type: 'oneof' as const,
label: 'Kind',
options: [
{ value: 'script', label: 'Script' },
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
{ value: 'app', label: 'App' }
]
},
archived: { type: 'boolean' as const, label: 'Only archived' },
// include_library and only_user_folders are role-dependent, but their KEYS stay unconditional
// (toggling `hidden` instead): useUrlSyncedFilterInstance snapshots the key set once and Home
// survives workspace switches, so a key first appearing after a role change would never
// URL-sync. The searchbar hides the inactive ones.
include_library: {
type: 'boolean' as const,
label: 'Include library scripts',
// On by default, so selecting it means "turn it off" — keep the picker.
default: true,
hidden: !($userStore && !$userStore.operator)
},
only_user_folders: {
type: 'boolean' as const,
label:
filterUserFoldersType === 'only f/*'
? 'Only f/*'
: `Only u/${$userStore?.username} and f/*`,
hidden: !filterUserFoldersType
}
} satisfies FilterSchemaRec)
// Legacy Home links stored free-text in `search`, owner scope in `filter`, and could carry
// `kind=all` — none of which the generic searchbar sync (keys `_default_`, `owner`, and a kind
// enum without `all`) understands. Rewrite them once, before the sync reads window.location, so
// shared/bookmarked URLs still restore and an invalid `kind=all` can't wedge later edits.
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
const p = url.searchParams
let changed = false
const legacySearch = p.get('search')
if (legacySearch !== null) {
if (!p.has('_default_')) p.set('_default_', legacySearch)
p.delete('search')
changed = true
}
const legacyOwner = p.get('filter')
if (legacyOwner !== null) {
if (!p.has('owner')) p.set('owner', legacyOwner)
p.delete('filter')
changed = true
}
if (p.get('kind') === 'all') {
p.delete('kind')
changed = true
}
if (changed) {
history.replaceState(
history.state,
'',
`${url.pathname}${p.toString() ? `?${p}` : ''}${url.hash}`
)
}
}
// Single URL-synced source of truth for the searchbar-driven filters.
let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema))
// Derived views the rest of the data layer reads. The merged-endpoint reload effect
// depends on these (search, kind, archived, library, user-folder scope), so changing a
// searchbar chip reloads the server stream exactly as toggling the old controls did.
let filter = $derived((filterValues.val._default_ ?? '') as string)
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
let archived = $derived(!!filterValues.val.archived)
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
// Content search is a distinct mode: its results come from ContentSearchInner
// (which carries only path + content), so the row-list filters can't
// apply to it. When it's active we restrict the searchbar to just the content filter,
// clear any other filters so they don't linger as ignored chips, and hide the row-list
// controls (kind toggle, tree view) that no longer drive anything.
let contentActive = $derived(!!filterValues.val.content)
let searchbarSchema = $derived(
contentActive ? { content: searchFilterSchema.content } : searchFilterSchema
)
$effect(() => {
if (!contentActive) return
untrack(() => {
for (const k of Object.keys(filterValues.val)) {
if (k !== 'content') delete (filterValues.val as Record<string, unknown>)[k]
}
})
})
// Content-filter view: reuse the Ctrl-K "Content" search. It loads its own dataset
// via `.open()`, then filters client-side by `search`. The component is
// keyed by workspace in the markup, so a workspace switch remounts it (this `bind:this`
// then points at the fresh instance and re-runs `open()`); the old instance is discarded,
// so its late in-flight responses can't overwrite the new workspace's results.
let contentSearchEl: ContentSearchInner | undefined = $state()
$effect(() => {
const el = contentSearchEl
if (el) untrack(() => el.open())
})
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
@@ -178,10 +303,6 @@
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
let itemKind = $state(
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
)
let loading = $state(true)
let nbDisplayed = $state(15)
@@ -596,8 +717,11 @@
return true // should not happen
}
let ownerFilter: string | undefined = $state(undefined)
let labelFilter: string | undefined = $state(undefined)
// Owner/label scope now live on the URL-synced searchbar filters (set via the presets),
// not standalone chip state — the whole data layer below still reads these two, so keep
// them as the single derived source. Empty string reads as "no filter".
let ownerFilter = $derived((filterValues.val.owner || undefined) as string | undefined)
let labelFilter = $derived((filterValues.val.label || undefined) as string | undefined)
const cmp = new Intl.Collator('en').compare
@@ -692,20 +816,8 @@
}
}
let archived = $state(false)
const TREE_VIEW_SETTING_NAME = 'treeView'
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
$userStore?.non_member
? 'only f/*'
: $userStore?.is_admin || $userStore?.is_super_admin
? 'u/username and f/*'
: undefined
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
// Pipeline entries are rendered independently of the item list, so apply the
// same gates the items get — otherwise a pipeline would still show under the
@@ -725,16 +837,6 @@
)
)
})
let includeWithoutMain = $state(
getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME)
? getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME) == 'true'
: true
)
const openSearchWithPrefilledText: (t?: string) => void = getContext(
'openSearchWithPrefilledText'
)
let viewCodeDrawer: Drawer | undefined = $state()
let viewCodeTitle: string | undefined = $state()
let script: Script | undefined = $state()
@@ -1056,15 +1158,25 @@
let allLabels = $derived(
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
)
// FilterSearchbar presets: the owner prefixes and labels the list actually holds, so
// scoping to one is a click in the searchbar dropdown instead of a wall of on-page chips.
// Owner sets the `owner` filter (server path-scope), label sets `label` (client filter).
// The `:\ ` separator and escaped spaces match the canonical `key:\ value` form parseToText
// emits, so the "already applied" check finds them after a reparse and won't re-offer a
// duplicate.
let searchPresets = $derived([
...owners.map((o) => ({ name: o, value: `owner:\\ ${o.replace(/ /g, '\\ ')}` })),
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l.replace(/ /g, '\\ ')}` }))
])
let prevWorkspace: string | undefined = undefined
// Clear filters only when the workspace actually changes. The initial
// resolution must be left alone so URL-loaded filter values (set by
// ListFilters.loadFilterFromUrl on mount) survive the async store settling.
// An owner/label from one workspace means nothing in another, so drop them when the
// workspace actually changes. The initial resolution is left alone so URL-loaded filter
// values survive the async store settling.
$effect(() => {
const ws = $workspaceStore
if (ws && prevWorkspace !== undefined && ws !== prevWorkspace) {
ownerFilter = undefined
labelFilter = undefined
delete filterValues.val.owner
delete filterValues.val.label
}
prevWorkspace = ws
})
@@ -1211,6 +1323,26 @@
selectedIndex = previousNbDisplayed
}
// The searchbar is a contenteditable, not an <input>, so it can't be matched by SKIP_SELECTOR
// and has no `.value`/`.selectionEnd`. It owns the arrows only while its suggestion dropdown is
// open (free-text mode passes them through to the list); track that so nav stands down then.
let searchbarDropdownOpen = $state(false)
// Caret position inside the searchbar's contenteditable, via the Selection API — the equivalent
// of an <input>'s selectionStart/End the list navigation used before the searchbar swap.
function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } {
const text = el.textContent ?? ''
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0)
return { atStart: true, atEnd: true, empty: text.length === 0 }
const range = sel.getRangeAt(0)
const pre = range.cloneRange()
pre.selectNodeContents(el)
pre.setEnd(range.endContainer, range.endOffset)
const caret = pre.toString().length
return { atStart: caret === 0, atEnd: caret >= text.length, empty: text.length === 0 }
}
// Elements that own the keyboard themselves (menus, dialogs, comboboxes): the
// list's own shortcuts stand down while one of them has focus.
const SKIP_SELECTOR =
@@ -1331,6 +1463,8 @@
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
const isOurSearch = target.id === 'home-search-input'
if (isEditable && !isOurSearch) return
// While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself.
if (isOurSearch && searchbarDropdownOpen) return
if (target.closest(skipSelector)) return
}
const active = document.activeElement as HTMLElement | null
@@ -1340,8 +1474,8 @@
// Guard: if cursor is in the middle of typed search text, let the cursor move.
if (e.key === 'ArrowRight') {
if (target?.id === 'home-search-input') {
const inp = target as HTMLInputElement
if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return
const c = searchCaret(target)
if (!c.empty && !c.atEnd) return
}
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
const buttons = getSelectedRowActionButtons()
@@ -1354,8 +1488,8 @@
// ArrowLeft from search input with cursor at start: no-op (let default handle).
if (e.key === 'ArrowLeft') {
if (target?.id === 'home-search-input') {
const inp = target as HTMLInputElement
if (inp.value.length > 0 && inp.selectionStart !== 0) return
const c = searchCaret(target)
if (!c.empty && !c.atStart) return
}
return
}
@@ -1427,13 +1561,6 @@
$effect(() => {
storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined)
})
$effect(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
$effect(() => {
storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
})
// Multi-selection + bulk actions. Published through context so the tree's
// nested levels don't have to carry it; `Item` is the only reader.
const homeSelection = new HomeSelection()
@@ -1497,157 +1624,77 @@
description: 'Lists of scripts, flows, and apps'
}}
>
<div class="flex justify-start">
<ToggleButtonGroup
bind:selected={itemKind}
onSelected={(v) => {
if (itemKind != 'all') {
subtab = v
}
setQuery('kind', v)
}}
>
{#snippet children({ item })}
<ToggleButton value="all" label="All" size="md" {item} />
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
{#if HOME_SEARCH_SHOW_FLOW}
{#if !contentActive}
<div class="flex justify-start">
<ToggleButtonGroup
selected={itemKind}
onSelected={(v) => {
// itemKind is derived from the shared filter object (which URL-syncs itself);
// `all` clears the kind filter (delete, not null, so it doesn't linger as a
// `kind: null` chip).
if (v === 'all') {
delete filterValues.val.kind
} else {
filterValues.val.kind = v
subtab = v
}
}}
>
{#snippet children({ item })}
<ToggleButton value="all" label="All" size="md" {item} />
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
{#if HOME_SEARCH_SHOW_FLOW}
<ToggleButton
value="flow"
label="Flows"
icon={FlowIcon}
selectedColor="#14b8a6"
size="md"
{item}
/>
{/if}
<ToggleButton
value="flow"
label="Flows"
icon={FlowIcon}
selectedColor="#14b8a6"
value="app"
label="Apps"
icon={LayoutDashboard}
selectedColor="#fb923c"
size="md"
{item}
/>
{/if}
<ToggleButton
value="app"
label="Apps"
icon={LayoutDashboard}
selectedColor="#fb923c"
size="md"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="relative text-primary grow min-w-[100px]">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
autofocus: true,
placeholder: HOME_SEARCH_PLACEHOLDER,
id: 'home-search-input'
}}
size="md"
bind:value={filter}
class="!pr-10"
/>
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
<svg
class="h-4 w-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 56.966 56.966"
style="enable-background:new 0 0 56.966 56.966;"
xml:space="preserve"
width="512px"
height="512px"
>
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
/>
</svg>
</button>
</div>
<Button
on:click={() => openSearchWithPrefilledText('#')}
variant="default"
unifiedSize="md"
endIcon={{
icon: SearchCode
}}
>
Content
</Button>
</div>
<div class="relative">
<ListFilters
syncQuery
bind:selectedFilter={ownerFilter}
filters={owners}
maxDisplayed={20}
bottomMargin={false}
/>
{#if allLabels.length > 0}
<div class="gap-1.5 w-full flex flex-wrap mt-2">
{#each allLabels as label (label)}
<Badge
color="blue"
small
clickable
selected={label === labelFilter}
title="Label: {label}"
onclick={() => {
labelFilter = labelFilter === label ? undefined : label
}}
>
<Tag size={10} class="inline -mt-px" />{label}
{#if label === labelFilter}&cross;{/if}
</Badge>
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{#if filteredItems?.length == 0}
<div class="mt-10"></div>
{/if}
{#if !loading}
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
<Popover floatingConfig={{ placement: 'bottom-end' }}>
{#snippet trigger()}
<Button
startIcon={{
icon: ListFilterPlus
}}
nonCaptureEvent
iconOnly
size="xs"
color="light"
variant="default"
spacingSize="xs2"
/>
{/snippet}
{#snippet content()}
<div class="p-4">
<span class="text-sm font-semibold text-emphasis">Filters</span>
<div class="flex flex-col gap-2 mt-2">
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
{#if $userStore && !$userStore.operator}
<Toggle
size="xs"
bind:checked={includeWithoutMain}
options={{ right: 'Include library scripts' }}
/>
{/if}
</div>
</div>
{/snippet}
</Popover>
{#if filterUserFoldersType === 'only f/*'}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if filterUserFoldersType === 'u/username and f/*'}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore?.username} and f/*` }}
{#if !loading && !contentActive}
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
view, expand/collapse (tree only), sort. -->
<div class="flex items-center gap-2">
{#if homeSelection.available && !homeSelection.active}
<Button
startIcon={{ icon: CheckSquare }}
iconOnly
unifiedSize="xs"
variant="default"
title="Select items — move, archive, delete or discard several at once"
on:click={() => homeSelection.enter()}
/>
{/if}
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
{#if treeView}
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => (collapseAll = !collapseAll)}
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
>
{#if collapseAll}
Expand all
{:else}
Collapse all
{/if}
</Button>
{/if}
<DropdownV2
items={sortItems}
disabled={filter !== ''}
@@ -1661,10 +1708,8 @@
nonCaptureEvent
disabled={filter !== ''}
iconOnly={short === ''}
size="xs"
color="light"
unifiedSize="xs"
variant="default"
spacingSize="xs2"
startIcon={{ icon: ArrowDownUp }}
title={filter !== ''
? 'Sorting is disabled while searching (results are ranked by relevance)'
@@ -1674,42 +1719,48 @@
</Button>
{/snippet}
</DropdownV2>
{#if treeView}
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => (collapseAll = !collapseAll)}
startIcon={{
icon: collapseAll ? ChevronsUpDown : ChevronsDownUp
}}
>
{#if collapseAll}
Expand all
{:else}
Collapse all
{/if}
</Button>
{/if}
{#if homeSelection.available && !homeSelection.active}
<!-- Last child of a flex-row-reverse row, so `mr-auto` absorbs the free
space and pins it to the far left, away from the view/sort controls. -->
<Button
wrapperClasses="mr-auto"
startIcon={{ icon: CheckSquare }}
iconOnly
size="xs"
color="light"
variant="default"
spacingSize="xs2"
title="Select items — move, archive, delete or discard several at once"
on:click={() => homeSelection.enter()}
/>
{/if}
</div>
{/if}
<div class="flex grow items-center justify-end gap-2 min-w-0">
<div class="relative text-primary w-full min-w-[200px] max-w-[26rem]">
<FilterSearchbar
schema={searchbarSchema}
bind:value={filterValues.val}
placeholder={HOME_SEARCH_PLACEHOLDER}
presets={contentActive ? [] : searchPresets}
autofocus
hideDropdownOnFreeText
inputId="home-search-input"
onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)}
/>
</div>
<!-- Same gate the old create actions used: hidden from operators and in workspaces
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
the menu itself does no permission check. -->
{#if !$userStore?.operator && showEditButtons}
<CreateActionsMenu />
{/if}
</div>
</div>
<div>
{#if filteredItems == undefined || treeCountsPending}
{#if filteredItems?.length == 0}
<div class="mt-10"></div>
{/if}
<div class="mt-3">
{#if filterValues.val.content}
<!-- Content filter: swap the normal list/tree for the content-match view (the same one
used by the Ctrl-K "Content" modal). It loads the workspace's scripts/flows/apps/
resources and matches their contents client-side — usable on any instance. Keyed by
workspace so a switch remounts a fresh instance and late in-flight responses from
the previous workspace can't land in it. -->
<!-- -mx-2 cancels ContentSearchInner's own px-2 so its rows line up flush with the
runnable list instead of sitting slightly inset. -->
<div class="-mx-2">
{#key $workspaceStore}
<ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} />
{/key}
</div>
{:else if filteredItems == undefined || treeCountsPending}
<div class="mt-4"></div>
<Skeleton layout={[[2], 1]} />
{#each new Array(6) as _}
@@ -30,7 +30,6 @@
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-lg font-semibold text-emphasis">Welcome to Windmill</div>
<div class="text-xs font-normal text-hint">
Get started by creating your first script, flow, or app
</div>
+105 -100
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import TreeView from './TreeView.svelte'
import { onDestroy, untrack } from 'svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
import Item from './Item.svelte'
@@ -297,120 +298,124 @@
{/if}
</button>
</div>
{#if opened || isSearching}
<div>
{#if hasPipeline && isFolder(item)}
<!-- py-3 matches common/table/Row.svelte so this row sits at
<!-- ResizeObserver, not a slide: a freshly-opened owner fetches its rows, so its height
changes twice (open-empty, then rows land) and a slide would only animate the first. -->
<ResizeTransitionWrapper vertical innerClass="w-full">
{#if opened || isSearching}
<div>
{#if hasPipeline && isFolder(item)}
<!-- py-3 matches common/table/Row.svelte so this row sits at
the same height as the script/flow/app rows that follow
it under the same folder; py-2 was visibly shorter. -->
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(depth + 1) * 16}px;"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis">Pipeline</span>
</a>
{/if}
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
<TreeView
{isSearching}
{collapseAll}
item={subItem}
{pipelineFolders}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
parentPrefix={nodePrefix}
ancestorHasMore={nodeHasMore}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
{showCode}
{showEditButton}
depth={depth + 1}
/>
{/each}
{#if effectiveMax < item.items.length}
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
is one row standing for everything under it. -->
<span class="text-xs text-secondary">
Showing {effectiveMax} of {item.items.length} loaded rows
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
// Grown from what is rendered, not from showMax: the lazy ceiling can
// already be showing more than showMax, and stepping that would take
// several clicks to change anything on screen.
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
}}
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(depth + 1) * 16}px;"
>
Show more
</Button>
</div>
{/if}
{#if nodePrefix != undefined && ownerLoad != undefined}
{#if nodeState?.loading && item.items.length === 0}
<!-- Show the spinner only on the first load, when there's nothing yet. A
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis">Pipeline</span>
</a>
{/if}
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
<TreeView
{isSearching}
{collapseAll}
item={subItem}
{pipelineFolders}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
parentPrefix={nodePrefix}
ancestorHasMore={nodeHasMore}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
{showCode}
{showEditButton}
depth={depth + 1}
/>
{/each}
{#if effectiveMax < item.items.length}
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
is one row standing for everything under it. -->
<span class="text-xs text-secondary">
Showing {effectiveMax} of {item.items.length} loaded rows
</span>
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => {
// Grown from what is rendered, not from showMax: the lazy ceiling can
// already be showing more than showMax, and stepping that would take
// several clicks to change anything on screen.
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
}}
>
Show more
</Button>
</div>
{/if}
{#if nodePrefix != undefined && ownerLoad != undefined}
{#if nodeState?.loading && item.items.length === 0}
<!-- Show the spinner only on the first load, when there's nothing yet. A
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
in place, so flashing "Loading…" under them would just be noise. -->
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
<!-- Every folder pages within its own prefix, so completing a subfolder
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
<!-- Every folder pages within its own prefix, so completing a subfolder
doesn't mean paging everything its owner holds. Under "expand all" this
waits for the client "Show more" above, so the two pagers don't stack
under every open node at once — but never while loading, or a long run
would unmount its own spinner on its first page. Spelling out the counts
is the point: without them this reads as an optional extra rather than
as rows still missing. -->
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
</span>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="subtle"
loading={nodeState?.loading && !loadingAll}
disabled={nodeState?.loading}
on:click={() =>
nodePrefix != undefined &&
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
>
Load more
</Button>
<!-- Same call, paged to the end: a folder several pages deep otherwise
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
</span>
<div class="flex flex-row items-center gap-2 shrink-0">
<Button
unifiedSize="sm"
variant="subtle"
loading={nodeState?.loading && !loadingAll}
disabled={nodeState?.loading}
on:click={() =>
nodePrefix != undefined &&
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
>
Load more
</Button>
<!-- Same call, paged to the end: a folder several pages deep otherwise
takes a click per page to reach an exact count. -->
<Button
unifiedSize="sm"
variant="subtle"
loading={nodeState?.loading && loadingAll}
disabled={nodeState?.loading}
on:click={() => {
if (nodePrefix == undefined) return
loadingAll = true
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
}}
>
Load all
</Button>
<Button
unifiedSize="sm"
variant="subtle"
loading={nodeState?.loading && loadingAll}
disabled={nodeState?.loading}
on:click={() => {
if (nodePrefix == undefined) return
loadingAll = true
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
}}
>
Load all
</Button>
</div>
</div>
</div>
{/if}
{/if}
{/if}
</div>
{/if}
</div>
{/if}
</ResizeTransitionWrapper>
</div>
{:else}
<Item
@@ -217,7 +217,9 @@ export function buildRunsFilterSearchbarSchema({
show_future_jobs: {
type: 'boolean' as const,
label: 'Show future jobs (Default: true)',
description: 'Include jobs that are planned later'
description: 'Include jobs that are planned later',
// On by default (useJobsLoader), so selecting it means "turn it off" — keep the picker.
default: true
},
...(isSuperAdminOrDevops &&
isAdminsWorkspace && {
@@ -15,6 +15,7 @@
class: className = '',
innerClass = '',
maxHeight = 256,
instantClose = false,
getInputRect,
children
}: {
@@ -26,6 +27,9 @@
class?: string
innerClass?: string
maxHeight?: number
// Close with no height-collapse animation (still animates open). Used where the
// dropdown is toggled off as the user types, so the panel vanishes at once.
instantClose?: boolean
getInputRect?: () => DOMRect
children?: Snippet
} = $props()
@@ -71,7 +75,11 @@
// We do not use Svelte transitions because they can not animate in the opposite direction
// when the dropdown is opens above the input
// Also CSS transitions are smoother because they do not rely on JS / animation frames
let uiState = $state({ domExists: untrack(() => open), visible: untrack(() => open), timeout: null as number | null })
let uiState = $state({
domExists: untrack(() => open),
visible: untrack(() => open),
timeout: null as number | null
})
let initial = true
watch(
() => open && !disabled,
@@ -81,7 +89,10 @@
initial = false
return
}
if (reducedMotion.val) {
// Reduced motion skips all animation; instantClose skips only the closing
// one (open still animates) so the panel disappears the instant it's toggled off.
if (reducedMotion.val || (instantClose && !isOpen)) {
if (uiState.timeout) clearTimeout(uiState.timeout)
uiState = {
domExists: open && !disabled,
visible: open && !disabled,
@@ -83,7 +83,11 @@ export async function openEditorInSession(
previewParams?: Record<string, string>,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
await openInSession(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId, opts)
await openInSession(
withPreviewParams(sessionTargetHref(target), previewParams),
workspaceId,
opts
)
}
// Open a fresh AI session showing a workspace page (Runs, a trigger list) in its
@@ -81,6 +81,11 @@
size?: ButtonType.UnifiedSize
unifiedHeight?: boolean
underlyingInputEl?: UnderlyingInputElT
/**
* Passed to the `autosize` action on the `textarea` variant. Chiefly `minHeight: 0`, for a
* field that hugs one line instead of reserving the action's 30px floor.
*/
autosizeParams?: import('$lib/autosize').AutosizeParams
}
export function focus() {
@@ -108,7 +113,8 @@
error,
size = 'md',
unifiedHeight = true,
underlyingInputEl: _underlyingInputEl
underlyingInputEl: _underlyingInputEl,
autosizeParams
}: Props<UnderlyingInputElT> = $props()
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
@@ -152,7 +158,7 @@
onpointerdown={(e) => e.stopImmediatePropagation()}
bind:this={inputEl}
bind:value
use:autosize
use:autosize={autosizeParams}
></textarea>
{:else if underlyingInputEl === 'input'}
<input
@@ -5,22 +5,12 @@
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from '$lib/components/home/FlowIcon.svelte'
import CreateActionsMenu from '$lib/components/home/CreateActionsMenu.svelte'
import { getScriptByPath } from '$lib/scripts'
import type { HubItem } from '$lib/components/flows/pickers/model'
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import HomeConnectDrawer from '$lib/components/home/HomeConnectDrawer.svelte'
import {
ExternalLink,
GitFork,
Globe2,
Loader2,
Code,
LayoutDashboard,
PlugZap
} from 'lucide-svelte'
import { ExternalLink, GitFork, Globe2, Loader2, Code, LayoutDashboard } from 'lucide-svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { base } from '$lib/base'
@@ -28,7 +18,6 @@
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
import { writable } from 'svelte/store'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { HOME_SHOW_HUB } from '$lib/consts'
import { setQuery } from '$lib/navigation'
import { page } from '$app/state'
import { goto, replaceState } from '$app/navigation'
@@ -42,6 +31,8 @@
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
type Tab = 'hub' | 'workspace'
@@ -97,7 +88,6 @@
}
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// Provide workspaceTutorials to child components via a reactive wrapper
let workspaceTutorialsContext = $derived(workspaceTutorials)
@@ -272,49 +262,23 @@
>
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full">
{#if $workspaceStore == 'admins'}
<div class="my-4"></div>
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
so it shows whenever the sessions beta is on; the composer itself is gated on operator
status inside the component (operators are refused by /sessions). -->
{#if isGlobalAiEnabled()}
<div class="w-full mb-16 mt-2">
<HomeAIChat />
</div>
{/if}
{#if $workspaceStore == 'admins'}
<Alert title="Admins workspace">
The Admins workspace is for admins only and contains scripts whose purpose is to manage your
Windmill instance, such as keeping resource types up to date.
</Alert>
<div class="my-4"></div>
{/if}
<div class="flex flex-row flex-wrap justify-between items-center gap-3 pb-2 my-4 mr-2 min-h-16">
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap leading-6 tracking-tight">
Home
</h1>
<div class="ml-auto flex flex-row gap-2 items-center">
{#if !$userStore?.operator && HOME_SHOW_HUB}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Globe2 }}
endIcon={{ icon: ExternalLink }}
href={$hubBaseUrlStore}
target="_blank"
btnClasses="whitespace-nowrap"
>
Hub
</Button>
{/if}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{#if !$userStore?.operator && showCreateButtons}
<div class="ml-2">
<CreateActionsMenu />
</div>
{/if}
</div>
</div>
<TutorialBanner />
@@ -396,9 +360,8 @@
</div>
{#if tab == 'workspace'}
<ItemsList bind:filter={getFilter, setFilter} bind:subtab showEditButtons={showCreateButtons} />
<ItemsList bind:subtab showEditButtons={showCreateButtons} />
{/if}
</div>
<WorkspaceTutorials bind:this={workspaceTutorials} />
<HomeConnectDrawer bind:this={homeConnectDrawer} />
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.799.0"
wmill = ">=1.800.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.799.0
version: 1.800.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.799.0'
ModuleVersion = '1.800.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.799.0"
version = "1.800.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.799.0",
"version": "1.800.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.799.0",
"version": "1.800.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.799.0
1.800.0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.799.0",
"version": "1.800.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.799.0",
"version": "1.800.0",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-yaml-validator",
"version": "1.799.0",
"version": "1.800.0",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",