mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into remove-workspace-drafts
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441))
|
||||
|
||||
## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee))
|
||||
* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7))
|
||||
* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4))
|
||||
* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020))
|
||||
* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd))
|
||||
* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3))
|
||||
* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb))
|
||||
* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4))
|
||||
* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946))
|
||||
|
||||
## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28)
|
||||
|
||||
|
||||
|
||||
+1
-3
@@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
|
||||
bun run cli -- run flow --record
|
||||
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
|
||||
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview
|
||||
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
|
||||
bun run cli -- run global global-test1-script-create
|
||||
bun run cli -- run cli bun-hello-script
|
||||
@@ -89,8 +89,6 @@ Today:
|
||||
- `opus`
|
||||
- `4o`
|
||||
- `gpt-5.5`
|
||||
- `gemini-flash`
|
||||
- `gemini-pro`
|
||||
- `gemini-3-flash-preview`
|
||||
- `gemini-3.1-pro-preview`
|
||||
- `deepseek-v4-flash`
|
||||
|
||||
@@ -21,9 +21,9 @@ describe("proxy helpers", () => {
|
||||
|
||||
describe("resolveEvalModelProvider", () => {
|
||||
it("infers googleai from Gemini model ids", () => {
|
||||
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
|
||||
expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-flash",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => {
|
||||
});
|
||||
|
||||
it("preserves an explicit provider", () => {
|
||||
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
|
||||
expect(
|
||||
resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"),
|
||||
).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-pro",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
args:
|
||||
a: 4
|
||||
b: 5
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_flow
|
||||
judgeChecklist:
|
||||
- "the flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `sum_numbers`"
|
||||
@@ -25,6 +28,9 @@
|
||||
args:
|
||||
a: 2
|
||||
b: 3
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_flow
|
||||
judgeChecklist:
|
||||
- "the flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `sum_numbers`"
|
||||
@@ -42,6 +48,9 @@
|
||||
args:
|
||||
a: 7
|
||||
b: 8
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_flow
|
||||
judgeChecklist:
|
||||
- "the parent flow takes `a` and `b` as inputs"
|
||||
- "the main step is named `call_add_numbers`"
|
||||
@@ -426,6 +435,7 @@
|
||||
- return_schedule_status
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_flow
|
||||
- create_schedule
|
||||
toolCallArgs:
|
||||
- tool: create_schedule
|
||||
@@ -453,6 +463,7 @@
|
||||
- webhook_response
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_flow
|
||||
- create_trigger
|
||||
toolCallArgs:
|
||||
- tool: create_trigger
|
||||
|
||||
@@ -430,3 +430,191 @@
|
||||
judgeChecklist:
|
||||
- asks which script to update when the user refers to "this script" without selected or active editor context
|
||||
- does not guess a path or create a new script draft
|
||||
|
||||
- id: global-test15-human-postgres-resource
|
||||
prompt: |-
|
||||
I'm wiring the eval reporting database into this workspace.
|
||||
Can you stage a Postgres connection for it in the shared evals/global folder?
|
||||
Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`.
|
||||
Keep the credentials safe.
|
||||
This is just draft work for now.
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
validate:
|
||||
draftCountExactly: 2
|
||||
requiredDrafts:
|
||||
- type: variable
|
||||
pathStartsWith: f/evals/global/
|
||||
pathIncludes:
|
||||
- evals
|
||||
- global
|
||||
- report
|
||||
- password
|
||||
valueIncludes:
|
||||
- "true"
|
||||
- report
|
||||
- type: resource
|
||||
pathStartsWith: f/evals/global/
|
||||
pathIncludes:
|
||||
- evals
|
||||
- global
|
||||
- report
|
||||
valueIncludes:
|
||||
- postgres
|
||||
- reports-db.internal
|
||||
- "5432"
|
||||
- evals_reporting
|
||||
- report_reader
|
||||
- "$var:"
|
||||
valueExcludes:
|
||||
- pg-redacted-reporting-password
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_variable
|
||||
- search_resource_types
|
||||
- write_resource
|
||||
forbiddenToolsUsed:
|
||||
- write_schedule
|
||||
- write_trigger
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: write_variable
|
||||
field: value
|
||||
stringStartsWithAnyOf:
|
||||
- pg-redacted-reporting-password
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates a Postgres resource draft for the eval reporting database
|
||||
- creates a secret variable draft for the database password
|
||||
- puts the drafts in sensible eval/global reporting-related paths
|
||||
- uses the requested host, port, database, and user
|
||||
- references the secret variable from the resource instead of embedding the password
|
||||
- leaves the work as a draft only
|
||||
|
||||
- id: global-test16-human-visible-variable
|
||||
prompt: |-
|
||||
We keep reusing a 30 day trial cutoff in eval notification jobs.
|
||||
Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls?
|
||||
It is not a secret.
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: variable
|
||||
pathStartsWith: f/evals/global/
|
||||
pathIncludes:
|
||||
- evals
|
||||
- global
|
||||
- trial
|
||||
valueIncludes:
|
||||
- "30"
|
||||
- "false"
|
||||
- trial
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_variable
|
||||
forbiddenToolsUsed:
|
||||
- write_resource
|
||||
- write_schedule
|
||||
- write_trigger
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- creates exactly one non-secret variable draft for the trial cutoff
|
||||
- stores the value 30
|
||||
- chooses a sensible eval/global path related to trials or notifications
|
||||
- includes a useful description of what the value controls
|
||||
- does not create resources, schedules, triggers, or deployed workspace changes
|
||||
|
||||
- id: global-test17-human-schedule-existing-helper
|
||||
prompt: |-
|
||||
The workspace already has a report digest helper.
|
||||
Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on?
|
||||
I only want the schedule draft for review.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: schedule
|
||||
pathIncludes:
|
||||
- digest
|
||||
valueIncludes:
|
||||
- f/evals/global/send_report_digest
|
||||
- UTC
|
||||
- dry_run
|
||||
- "true"
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- list_workspace_items
|
||||
- write_schedule
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- write_flow
|
||||
- write_resource
|
||||
- write_variable
|
||||
- write_trigger
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- finds the existing report digest helper rather than creating a new script or flow
|
||||
- creates one schedule draft for that helper
|
||||
- schedules it for weekdays around 08:30 UTC
|
||||
- passes dry_run as true
|
||||
- leaves only the schedule draft for review
|
||||
|
||||
- id: global-test18-human-slack-resource-with-secret
|
||||
prompt: |-
|
||||
I'm preparing Slack notifications for eval failures.
|
||||
Can you stage a Slack connection in the shared evals/global folder?
|
||||
The bot token is `xoxb-redacted-test-token`; keep it safe.
|
||||
Don't deploy anything yet.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 2
|
||||
requiredDrafts:
|
||||
- type: variable
|
||||
pathStartsWith: f/evals/global/
|
||||
pathIncludes:
|
||||
- evals
|
||||
- global
|
||||
- slack
|
||||
- token
|
||||
valueIncludes:
|
||||
- "true"
|
||||
- type: resource
|
||||
pathStartsWith: f/evals/global/
|
||||
pathIncludes:
|
||||
- evals
|
||||
- global
|
||||
- slack
|
||||
valueIncludes:
|
||||
- slack
|
||||
- "$var:"
|
||||
valueExcludes:
|
||||
- xoxb-redacted-test-token
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_variable
|
||||
- search_resource_types
|
||||
- write_resource
|
||||
forbiddenToolsUsed:
|
||||
- write_schedule
|
||||
- write_trigger
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: write_variable
|
||||
field: value
|
||||
stringStartsWithAnyOf:
|
||||
- xoxb-redacted-test-token
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates a secret variable draft for the Slack bot token placeholder
|
||||
- creates a Slack resource draft that references the secret variable instead of embedding the token
|
||||
- keeps both drafts under a sensible eval/global Slack-related path
|
||||
- does not create schedules, triggers, or deployed workspace changes
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
Keep it simple and do not add external dependencies.
|
||||
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_script
|
||||
judgeChecklist:
|
||||
- uses the existing `name` input
|
||||
- returns a plain greeting string
|
||||
@@ -20,6 +23,7 @@
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_script
|
||||
- create_schedule
|
||||
toolCallArgs:
|
||||
- tool: create_schedule
|
||||
@@ -44,6 +48,7 @@
|
||||
expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- test_run_script
|
||||
- create_trigger
|
||||
toolCallArgs:
|
||||
- tool: create_trigger
|
||||
|
||||
@@ -14,6 +14,21 @@ describe("loadCases", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(caseEntry?.toolExpect).toEqual({
|
||||
requiredToolsUsed: ["test_run_flow"],
|
||||
});
|
||||
});
|
||||
|
||||
it("loads script and flow test tool expectations", async () => {
|
||||
const scriptCases = await loadCases("script");
|
||||
const flowCases = await loadCases("flow");
|
||||
|
||||
expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({
|
||||
requiredToolsUsed: ["test_run_script"],
|
||||
});
|
||||
expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({
|
||||
requiredToolsUsed: ["test_run_flow"],
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the workspace-flow preference benchmark case", async () => {
|
||||
@@ -238,7 +253,7 @@ describe("loadCases", () => {
|
||||
);
|
||||
|
||||
expect(caseEntry?.toolExpect).toEqual({
|
||||
requiredToolsUsed: ["create_schedule"],
|
||||
requiredToolsUsed: ["test_run_script", "create_schedule"],
|
||||
toolCallArgs: [
|
||||
{
|
||||
tool: "create_schedule",
|
||||
|
||||
@@ -18,14 +18,6 @@ describe("resolveEvalModel", () => {
|
||||
});
|
||||
|
||||
it("supports Gemini aliases for frontend evals", () => {
|
||||
expect(resolveEvalModel("flow", "gemini").frontend).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-flash",
|
||||
});
|
||||
expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-pro",
|
||||
});
|
||||
expect(
|
||||
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
|
||||
).toEqual({
|
||||
@@ -52,8 +44,8 @@ describe("resolveEvalModel", () => {
|
||||
});
|
||||
|
||||
it("rejects Gemini aliases for cli evals", () => {
|
||||
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
|
||||
"Model gemini-flash is not supported for cli mode",
|
||||
expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow(
|
||||
"Model gemini-3-flash-preview is not supported for cli mode",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,24 +96,6 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-flash",
|
||||
label: "Gemini 2.5 Flash",
|
||||
aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"],
|
||||
frontend: {
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-pro",
|
||||
label: "Gemini 2.5 Pro",
|
||||
aliases: ["gemini-pro", "gemini-2.5-pro"],
|
||||
frontend: {
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash-preview",
|
||||
label: "Gemini 3 Flash Preview",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workspace": {
|
||||
"scripts": [
|
||||
{
|
||||
"path": "f/evals/global/send_report_digest",
|
||||
"summary": "Build and send the eval report digest",
|
||||
"description": "Returns a dry-run summary for eval report digest notifications.",
|
||||
"language": "bun",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dry_run": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["dry_run"]
|
||||
},
|
||||
"content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+142
-152
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
08e3b9b818f9f1b439e9a6ba477fd5fdbcab944e
|
||||
3742e0659c5e97aab03b9efeea14cd94a3ac658a
|
||||
|
||||
+24
-24
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6263,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6275,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6296,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6308,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6320,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6332,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6344,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6378,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6411,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6423,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6437,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6454,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6467,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6513,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6529,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6561,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.712.0"
|
||||
version = "1.713.1"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -258,6 +258,15 @@ pub fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
|
||||
// The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its
|
||||
// compiled default. Allow overriding it via env so the prebuild cache step can
|
||||
// be pointed at a private/staging hub (e.g. a local proxy for testing).
|
||||
if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") {
|
||||
if !hub_base_url.is_empty() {
|
||||
tracing::info!("Overriding hub base url from env: {hub_base_url}");
|
||||
windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url));
|
||||
}
|
||||
}
|
||||
let file_path = file_path.unwrap_or("./hubPaths.json".to_string());
|
||||
let mut file = File::open(&file_path)
|
||||
.await
|
||||
@@ -567,6 +576,7 @@ fn print_help() {
|
||||
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
|
||||
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
|
||||
println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start");
|
||||
println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)");
|
||||
println!();
|
||||
println!("Notes:");
|
||||
println!("- Advanced and less commonly used settings are managed via the database and are omitted here.");
|
||||
|
||||
@@ -451,6 +451,7 @@ def main():
|
||||
preserve_on_behalf_of: None,
|
||||
ws_error_handler_muted: None,
|
||||
labels: None,
|
||||
skip_draft_deletion: None,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
@@ -513,6 +514,7 @@ def main():
|
||||
custom_path: None,
|
||||
preserve_on_behalf_of: None,
|
||||
labels: None,
|
||||
skip_draft_deletion: None,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -20,7 +20,7 @@ where
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
|
||||
static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
|
||||
pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -235,6 +235,202 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the caller's "real" scope restrictions: every scope other than
|
||||
/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and
|
||||
/// has the full privileges of its user; `Some` means it is restricted to the
|
||||
/// returned scopes. An empty or filter-tags-only scope list is treated as
|
||||
/// unscoped, mirroring `check_scopes`/`check_route_access`.
|
||||
fn scope_restrictions(scopes: Option<&[String]>) -> Option<Vec<&String>> {
|
||||
let restrictions: Vec<&String> = scopes?
|
||||
.iter()
|
||||
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
||||
.collect();
|
||||
(!restrictions.is_empty()).then_some(restrictions)
|
||||
}
|
||||
|
||||
/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes
|
||||
/// a credential on behalf of `authed`: the resulting credential must never be
|
||||
/// more privileged than the caller's own token.
|
||||
///
|
||||
/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow).
|
||||
/// - A scope-restricted caller may only grant scopes that are a subset of its
|
||||
/// own, and may never produce an unscoped credential.
|
||||
///
|
||||
/// Without this, a `users:write` token could create or rescope a token to be
|
||||
/// unscoped, and a `users:read` token could refresh into an unscoped session —
|
||||
/// escaping its own restrictions.
|
||||
pub fn ensure_scopes_within_caller(
|
||||
authed: &ApiAuthed,
|
||||
requested_scopes: Option<&[String]>,
|
||||
) -> error::Result<()> {
|
||||
if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) {
|
||||
let Some(requested_restrictions) = scope_restrictions(requested_scopes) else {
|
||||
return Err(Error::PermissionDenied(
|
||||
"A scope-restricted token cannot create or update a token with broader (unscoped) \
|
||||
privileges"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a
|
||||
// custom format that ScopeDefinition::from_scope_string parses
|
||||
// permissively but the MCP runtime interprets via its own parser
|
||||
// (parse_mcp_scopes). The two views disagree — e.g. the generic parser
|
||||
// accepts `mcp:scripts` as an unrestricted-resource scope, while the
|
||||
// MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*`
|
||||
// as granting all scripts. So generic containment would silently allow
|
||||
// `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token
|
||||
// issuance goes through the OAuth gateway (mcp/oauth_server.rs), not
|
||||
// these user-token endpoints, so require byte-identical match for MCP
|
||||
// scopes here rather than trying to mirror MCP semantics in two places.
|
||||
// Unparseable non-MCP caller scopes are intentionally dropped
|
||||
// (fail-closed): a caller scope that fails to parse can only narrow
|
||||
// the set of requested scopes that get covered, never widen it.
|
||||
// Unparseable requested scopes surface as `BadRequest`, which is what
|
||||
// we want — the client is sending garbage.
|
||||
let parsed_caller: Vec<ScopeDefinition> = caller_restrictions
|
||||
.iter()
|
||||
.filter(|s| !s.starts_with("mcp:"))
|
||||
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
|
||||
.collect();
|
||||
let caller_mcp: std::collections::HashSet<&str> = caller_restrictions
|
||||
.iter()
|
||||
.filter(|s| s.starts_with("mcp:"))
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
for requested in requested_restrictions {
|
||||
if requested.starts_with("mcp:") {
|
||||
if !caller_mcp.contains(requested.as_str()) {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"A scope-restricted token cannot grant MCP scope '{requested}' unless the \
|
||||
caller holds the same scope verbatim"
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let requested_scope = ScopeDefinition::from_scope_string(requested)?;
|
||||
let covered = parsed_caller
|
||||
.iter()
|
||||
.any(|caller_scope| scope_contains(caller_scope, &requested_scope));
|
||||
if !covered {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"A scope-restricted token cannot grant scope '{requested}' which exceeds its \
|
||||
own scopes"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `if_jobs:filter_tags:` fences which job tags a token can run on (enforced
|
||||
// at job operations as `v2_job.tag = ANY(...)`), and is checked independently
|
||||
// of domain/action/resource subset. A caller restricted by filter_tags must
|
||||
// not be able to mint or rescope a credential that drops or widens the fence
|
||||
// — even if the caller has no other scope restrictions (filter_tags-only
|
||||
// tokens otherwise look "unscoped" to `scope_restrictions`).
|
||||
if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) {
|
||||
let Some(requested_tags) = first_filter_tags(requested_scopes) else {
|
||||
return Err(Error::PermissionDenied(
|
||||
"A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \
|
||||
drops the tag restriction"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect();
|
||||
for tag in &requested_tags {
|
||||
if !caller_set.contains(tag) {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \
|
||||
not within its own filter_tags"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tags from the first `if_jobs:filter_tags:<a,b,...>` scope, matching the
|
||||
/// semantics of [`get_scope_tags`] (which is what the job runtime consults).
|
||||
/// Returns `None` if no such scope is present.
|
||||
fn first_filter_tags(scopes: Option<&[String]>) -> Option<Vec<&str>> {
|
||||
scopes?.iter().find_map(|s| {
|
||||
s.strip_prefix("if_jobs:filter_tags:")
|
||||
.map(|tags| tags.split(',').collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `caller` grants at least everything `requested` grants (directional
|
||||
/// containment).
|
||||
///
|
||||
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
|
||||
/// "does this scope grant access to a required action" using OR semantics over
|
||||
/// resources (any overlap counts, and a `*` on either side matches), which is
|
||||
/// correct for access checks but unsafe for subset checks — it would let a
|
||||
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
|
||||
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
|
||||
/// EVERY requested resource is covered by SOME caller resource.
|
||||
fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
|
||||
if caller.domain != requested.domain {
|
||||
return false;
|
||||
}
|
||||
|
||||
// write subsumes read; otherwise the action must match exactly.
|
||||
match (caller.action.as_str(), requested.action.as_str()) {
|
||||
(c, r) if c == r || (c == "write" && r == "read") => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if caller.domain == "jobs" && caller.action == "run" {
|
||||
match (&caller.kind, &requested.kind) {
|
||||
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
|
||||
return false
|
||||
}
|
||||
// Caller pinned to a kind, but the request covers any kind.
|
||||
(Some(_), None) => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match (&caller.resource, &requested.resource) {
|
||||
// Caller is unrestricted on resources: covers everything.
|
||||
(None, _) => true,
|
||||
// Caller is resource-restricted but the request is not: broader.
|
||||
(Some(_), None) => false,
|
||||
(Some(caller_resources), Some(requested_resources)) => {
|
||||
resource_set_contains(caller_resources, requested_resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every resource in `requested` must be covered by some resource in `caller`.
|
||||
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
|
||||
if caller.iter().any(|r| r == "*") {
|
||||
return true;
|
||||
}
|
||||
requested
|
||||
.iter()
|
||||
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
|
||||
}
|
||||
|
||||
/// Directional: does the single caller resource pattern cover `requested`?
|
||||
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
|
||||
/// may itself be a subtree wildcard, in which case the whole requested subtree
|
||||
/// must fall within the caller's subtree.
|
||||
fn resource_covers(caller: &str, requested: &str) -> bool {
|
||||
if caller == requested {
|
||||
return true;
|
||||
}
|
||||
let Some(prefix) = caller.strip_suffix("/*") else {
|
||||
// An exact caller resource only covers itself (handled above).
|
||||
return false;
|
||||
};
|
||||
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
|
||||
requested_base == prefix
|
||||
|| (requested_base.starts_with(prefix)
|
||||
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Returns a predicate that checks whether `path` is within the token's
|
||||
/// scope for `{domain}:{action}:{path}`. For tokens without scope
|
||||
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
|
||||
@@ -574,6 +770,13 @@ impl NewToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-level token mint shared by trusted callers (the user-facing
|
||||
/// `tokens/create` handler and internal mints such as native-trigger webhook
|
||||
/// tokens). It does NOT enforce that `token_config.scopes` is within the
|
||||
/// caller's own scopes — callers exposed to untrusted input must call
|
||||
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
|
||||
/// skip it, since their scopes derive from the action being authorized, not the
|
||||
/// caller's token).
|
||||
pub async fn create_token_internal(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
db: &DB,
|
||||
@@ -908,4 +1111,278 @@ mod tests {
|
||||
assert!(allowed("u/alice/foo"));
|
||||
assert!(!allowed("u/alice/bar"));
|
||||
}
|
||||
|
||||
fn opt_scopes(scopes: Option<Vec<&str>>) -> Option<Vec<String>> {
|
||||
scopes.map(|v| v.into_iter().map(String::from).collect())
|
||||
}
|
||||
|
||||
// Regression tests for WIN-1999: scoped user tokens must not be able to
|
||||
// mint or rescope credentials with broader privileges than themselves.
|
||||
|
||||
#[test]
|
||||
fn unscoped_caller_can_grant_anything() {
|
||||
let authed = authed_with_scopes(None);
|
||||
assert!(ensure_scopes_within_caller(&authed, None).is_ok());
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() {
|
||||
// The domain/action/resource subset check treats filter-tags-only as
|
||||
// unrestricted, mirroring check_scopes/check_route_access. The tag
|
||||
// dimension is checked separately (see filter_tags_dimension_is_monotonic).
|
||||
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tags_dimension_is_monotonic() {
|
||||
// Caller restricted to tag fence "a" cannot drop the fence …
|
||||
let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"]));
|
||||
assert!(ensure_scopes_within_caller(&single, None).is_err());
|
||||
assert!(
|
||||
ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref())
|
||||
.is_err(),
|
||||
"minting a token without filter_tags must be rejected"
|
||||
);
|
||||
// … cannot widen to a tag it lacks …
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&single,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
// … and cannot mint a token fenced on a disjoint tag.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&single,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
// Narrowing or matching the tag fence is allowed.
|
||||
let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&multi,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&multi,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// A caller with a real scope plus a tag fence cannot drop just the fence.
|
||||
let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&mixed,
|
||||
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&mixed,
|
||||
opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// An unrestricted caller may grant filter_tags freely.
|
||||
let unscoped = authed_with_scopes(None);
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&unscoped,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_cannot_mint_unscoped_token() {
|
||||
// Primitive 2 in the report: a users:write token minting an unscoped token.
|
||||
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
||||
assert!(ensure_scopes_within_caller(&authed, None).is_err());
|
||||
// Empty scope list is effectively unscoped and must also be rejected.
|
||||
assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err());
|
||||
// A scope list of only tag filters is effectively unscoped too.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_cannot_remove_its_own_scopes() {
|
||||
// Primitive 3 in the report: a users:write token setting its scopes to null.
|
||||
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
||||
assert!(ensure_scopes_within_caller(&authed, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_cannot_grant_scope_it_lacks() {
|
||||
let authed = authed_with_scopes(Some(vec!["users:write"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_can_grant_subset_of_own_scopes() {
|
||||
let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"]));
|
||||
// Equal scope is allowed.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// write implies read, so a narrower read scope is allowed.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["users:read"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// Tag filters narrow further and are always permitted.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_cannot_broaden_resource_scope() {
|
||||
let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"]));
|
||||
// Narrower resource within the subtree is allowed.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// A nested subtree within the caller's subtree is allowed.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// The subtree root itself is allowed.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
// A path outside the subtree is rejected.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
// read caller cannot grant write.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&authed,
|
||||
opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_scopes_require_byte_identical_match() {
|
||||
// Regression for the access-grant-OR vs runtime-MCP-parser confusion:
|
||||
// ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope
|
||||
// and `mcp:scripts:*` as a strictly narrower one, so generic containment
|
||||
// would silently allow widening. The MCP runtime however ignores
|
||||
// `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts.
|
||||
// Legitimate MCP token issuance is the OAuth gateway, not these
|
||||
// user-token endpoints, so MCP scopes must match the caller verbatim.
|
||||
|
||||
// The bypass the reviewer flagged: malformed `mcp:scripts` would widen
|
||||
// into the real `mcp:scripts:*` under generic containment.
|
||||
let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&bypass,
|
||||
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// A caller without any MCP scope cannot grant one (widening on the MCP
|
||||
// dimension), even if the rest of the requested scopes are within reach.
|
||||
let no_mcp = authed_with_scopes(Some(vec!["users:write"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&no_mcp,
|
||||
opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Byte-identical MCP scope passes; an additional non-matching MCP scope
|
||||
// alongside it does not.
|
||||
let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&mcp_caller,
|
||||
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&mcp_caller,
|
||||
opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`)
|
||||
// is rejected by the byte-identical rule. This is intentional — these
|
||||
// endpoints are not the legitimate path for narrowing MCP tokens.
|
||||
let mcp_all = authed_with_scopes(Some(vec!["mcp:all"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&mcp_all,
|
||||
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_caller_cannot_escalate_to_wildcard_or_superset() {
|
||||
// Regression for the access-grant-OR vs subset-containment confusion:
|
||||
// ScopeDefinition::includes would (incorrectly) allow all of these.
|
||||
let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
|
||||
// Minting `*` from a single-path scope must be rejected.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&star,
|
||||
opt_scopes(Some(vec!["scripts:read:*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
// Minting a broader subtree must be rejected.
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&star,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// A comma-separated list that adds an uncovered resource must be rejected,
|
||||
// even though one element overlaps the caller's scope.
|
||||
let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&list,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// A subset of a multi-resource caller scope is allowed.
|
||||
let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&multi,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// A wildcard caller covers any subset, but not `*`-less escalation rules apply
|
||||
// only when the caller itself lacks `*`.
|
||||
let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"]));
|
||||
assert!(ensure_scopes_within_caller(
|
||||
&wildcard,
|
||||
opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,13 +551,17 @@ async fn create_flow(
|
||||
w_id
|
||||
).execute(&mut *tx).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
nf.path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// CLI / git-sync deploys ask us to preserve any existing user draft at this
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
nf.path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -1138,13 +1142,17 @@ async fn update_flow(
|
||||
})?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
flow_path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// CLI / git-sync deploys ask us to preserve any existing user draft at this
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
flow_path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -1956,6 +1964,7 @@ mod tests {
|
||||
})),
|
||||
preprocessor_module: None,
|
||||
same_worker: false,
|
||||
preserve_step_tags: false,
|
||||
skip_expr: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
|
||||
@@ -383,6 +383,8 @@ async fn remove_granular_acl(
|
||||
// workspace export.
|
||||
let table = if kind == "raw_app" { "app" } else { kind };
|
||||
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
|
||||
// LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can
|
||||
// return >1 row, which would break the scalar subquery in RETURNING.
|
||||
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
|
||||
"WITH old AS (
|
||||
SELECT extra_perms->$1 as old_write FROM {table}
|
||||
@@ -390,7 +392,7 @@ async fn remove_granular_acl(
|
||||
)
|
||||
UPDATE {table} SET extra_perms = extra_perms - $1
|
||||
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
|
||||
RETURNING (SELECT old_write FROM old)::bool"
|
||||
RETURNING (SELECT old_write FROM old LIMIT 1)::bool"
|
||||
))
|
||||
.bind(&owner)
|
||||
.bind(path)
|
||||
|
||||
@@ -614,6 +614,9 @@ async fn is_noop_deploy_against_parent(
|
||||
// caller-intent flag (auto-resolve parent), not script state
|
||||
auto_parent: _,
|
||||
labels,
|
||||
// caller-intent flag (preserve user drafts on CLI/git-sync deploys);
|
||||
// transient, never persisted, does not change what the script *is*
|
||||
skip_draft_deletion: _,
|
||||
} = ns;
|
||||
|
||||
if path != &parent.path {
|
||||
@@ -802,6 +805,9 @@ async fn create_script_internal<'c>(
|
||||
}
|
||||
}
|
||||
let script_path = ns.path.clone();
|
||||
// Caller-intent: CLI / git-sync deploys ask us to preserve any existing
|
||||
// user draft at this path instead of wiping it as part of the deploy.
|
||||
let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false);
|
||||
let hash = ScriptHash(hash_script(&ns));
|
||||
let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await;
|
||||
|
||||
@@ -1234,13 +1240,15 @@ async fn create_script_internal<'c>(
|
||||
|
||||
let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone());
|
||||
if let Some(ref p_path) = p_path_opt {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
p_path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
p_path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
|
||||
@@ -1319,7 +1327,7 @@ async fn create_script_internal<'c>(
|
||||
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
ns.path,
|
||||
|
||||
@@ -1964,7 +1964,8 @@ async fn login(
|
||||
windmill_common::login_rate_limit::record_login_failure(&email);
|
||||
Err(Error::BadRequest("Invalid login".to_string()))
|
||||
} else {
|
||||
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
|
||||
let token =
|
||||
create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?;
|
||||
|
||||
let audit_author = AuditAuthor {
|
||||
email: email.clone(),
|
||||
@@ -2036,7 +2037,15 @@ async fn refresh_token(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?;
|
||||
let new_token = create_session_token(
|
||||
&authed.email,
|
||||
super_admin,
|
||||
authed.scopes.as_deref(),
|
||||
authed.read_only,
|
||||
&mut tx,
|
||||
cookies,
|
||||
)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -2066,6 +2075,8 @@ lazy_static::lazy_static! {
|
||||
pub async fn create_session_token<'c>(
|
||||
email: &str,
|
||||
super_admin: bool,
|
||||
scopes: Option<&[String]>,
|
||||
read_only: bool,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
cookies: Cookies,
|
||||
) -> Result<String> {
|
||||
@@ -2108,15 +2119,17 @@ pub async fn create_session_token<'c>(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO token
|
||||
(token_hash, token_prefix, token, email, label, expiration, super_admin)
|
||||
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)",
|
||||
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)
|
||||
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
|
||||
t_hash,
|
||||
t_prefix,
|
||||
plaintext as Option<&str>,
|
||||
email,
|
||||
"session",
|
||||
&MAX_SESSION_VALIDITY_SECONDS.to_string(),
|
||||
super_admin
|
||||
super_admin,
|
||||
scopes,
|
||||
read_only,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -2146,6 +2159,8 @@ async fn create_token(
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_token_create_rate_limit(&authed.username)?;
|
||||
|
||||
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
|
||||
@@ -2353,6 +2368,8 @@ async fn update_token_scopes(
|
||||
Path(token_prefix): Path<String>,
|
||||
Json(req): Json<UpdateTokenScopesRequest>,
|
||||
) -> Result<String> {
|
||||
windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let updated: Option<String> = sqlx::query_scalar!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.712.0
|
||||
version: 1.713.1
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -9831,6 +9831,9 @@ paths:
|
||||
type: boolean
|
||||
deployment_message:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
|
||||
responses:
|
||||
"201":
|
||||
description: flow created
|
||||
@@ -9872,6 +9875,9 @@ paths:
|
||||
properties:
|
||||
deployment_message:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path."
|
||||
|
||||
responses:
|
||||
"200":
|
||||
@@ -10370,6 +10376,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -10422,6 +10431,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -10726,6 +10738,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
responses:
|
||||
"200":
|
||||
description: app updated
|
||||
@@ -10772,6 +10787,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
js:
|
||||
type: string
|
||||
css:
|
||||
@@ -22032,6 +22050,9 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
|
||||
|
||||
required:
|
||||
- path
|
||||
|
||||
@@ -305,6 +305,22 @@ async fn get_token_using_oauth(
|
||||
resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?;
|
||||
resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?;
|
||||
resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?;
|
||||
// Validate the resolved token_url against SSRF rules before issuing the request,
|
||||
// mirroring the protection applied to base_url in `get_base_url` (same
|
||||
// ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could
|
||||
// point token_url at an internal/metadata address.
|
||||
if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS {
|
||||
use windmill_common::ssrf::SsrfValidationError;
|
||||
windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
|
||||
"{e}. If you need to use private/internal AI endpoints, \
|
||||
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
|
||||
)),
|
||||
e => Error::from(e),
|
||||
})?;
|
||||
}
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "client_credentials");
|
||||
params.insert("scope", "https://cognitiveservices.azure.com/.default");
|
||||
|
||||
@@ -291,6 +291,11 @@ pub struct CreateApp {
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this app must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -304,6 +309,11 @@ pub struct EditApp {
|
||||
pub preserve_on_behalf_of: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this app must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -1294,13 +1304,17 @@ async fn create_app_internal<'a>(
|
||||
));
|
||||
}
|
||||
}
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
&app.path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// CLI / git-sync deploys ask us to preserve any existing user draft at this
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !app.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
&app.path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO app
|
||||
(workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)
|
||||
@@ -1899,13 +1913,17 @@ async fn update_app_internal<'a>(
|
||||
)));
|
||||
}
|
||||
};
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// CLI / git-sync deploys ask us to preserve any existing user draft at this
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !ns.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -39,23 +39,43 @@ pub struct StaticFile(Uri);
|
||||
impl IntoResponse for StaticFile {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
let original_path = self.0.path();
|
||||
let query = self.0.query();
|
||||
let path = original_path.trim_start_matches('/');
|
||||
serve_path(path, original_path)
|
||||
serve_path(path, original_path, query)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "static_frontend")]
|
||||
const TWO_HUNDRED: &str = "200.html";
|
||||
|
||||
/// Check if the original path requires cross-origin isolation headers
|
||||
/// Check if the original path requires cross-origin isolation headers.
|
||||
///
|
||||
/// These headers are needed for SharedArrayBuffer and TypeScript workers
|
||||
/// Only enabled for /apps_raw paths (raw app editor)
|
||||
/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`).
|
||||
///
|
||||
/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep`
|
||||
/// query param: a public (raw) app must set COEP to be embeddable as an iframe
|
||||
/// inside a cross-origin-isolated page (which requires the embedded document to
|
||||
/// also set COEP). It is opt-in rather than always-on because cross-origin
|
||||
/// isolation also blocks subresources without CORP (e.g. external image URLs
|
||||
/// or embeds used by classic apps), so we only enable it when the embedder
|
||||
/// explicitly requests it.
|
||||
#[cfg(feature = "static_frontend")]
|
||||
fn needs_cross_origin_isolation(original_path: &str) -> bool {
|
||||
original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/")
|
||||
fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool {
|
||||
original_path.starts_with("/apps_raw/")
|
||||
|| original_path.starts_with("/ui_builder/")
|
||||
|| ((original_path.starts_with("/public/") || original_path.starts_with("/a/"))
|
||||
&& query_has_flag(query, "wm_coep"))
|
||||
}
|
||||
|
||||
fn serve_path(path: &str, original_path: &str) -> Response<Body> {
|
||||
/// Returns true if `query` contains the given flag key (with or without a
|
||||
/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`.
|
||||
#[cfg(feature = "static_frontend")]
|
||||
fn query_has_flag(query: Option<&str>, flag: &str) -> bool {
|
||||
query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag)))
|
||||
}
|
||||
|
||||
fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response<Body> {
|
||||
if path.starts_with("api/") {
|
||||
return Response::builder().status(404).body(Body::empty()).unwrap();
|
||||
}
|
||||
@@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
|
||||
|
||||
// Add cross-origin isolation headers only for paths that need them
|
||||
// (apps_raw editor needs SharedArrayBuffer for TypeScript workers)
|
||||
if needs_cross_origin_isolation(original_path) {
|
||||
if needs_cross_origin_isolation(original_path, query) {
|
||||
res = res
|
||||
.header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
.header("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
@@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response<Body> {
|
||||
None if path.starts_with("_app/") => {
|
||||
Response::builder().status(404).body(Body::empty()).unwrap()
|
||||
}
|
||||
None => serve_path(TWO_HUNDRED, original_path),
|
||||
None => serve_path(TWO_HUNDRED, original_path, query),
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "static_frontend"))]
|
||||
{
|
||||
let _ = original_path; // suppress unused warning
|
||||
let _ = (original_path, query); // suppress unused warning
|
||||
Response::builder().status(404).body(Body::empty()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "static_frontend"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_query_has_flag() {
|
||||
assert!(query_has_flag(Some("wm_coep"), "wm_coep"));
|
||||
assert!(query_has_flag(Some("wm_coep=on"), "wm_coep"));
|
||||
assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep"));
|
||||
assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep"));
|
||||
assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep"));
|
||||
assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep"));
|
||||
assert!(!query_has_flag(Some(""), "wm_coep"));
|
||||
assert!(!query_has_flag(None, "wm_coep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_cross_origin_isolation() {
|
||||
// editor + bundler are always isolated, regardless of query
|
||||
assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None));
|
||||
assert!(needs_cross_origin_isolation("/ui_builder/index.html", None));
|
||||
|
||||
// public apps (and custom paths) are isolated only when they opt in via wm_coep
|
||||
assert!(needs_cross_origin_isolation(
|
||||
"/public/ws/secret",
|
||||
Some("wm_coep")
|
||||
));
|
||||
assert!(needs_cross_origin_isolation(
|
||||
"/public/ws/secret",
|
||||
Some("wm_coep=on")
|
||||
));
|
||||
assert!(needs_cross_origin_isolation(
|
||||
"/a/ws/my/path",
|
||||
Some("wm_coep=on")
|
||||
));
|
||||
assert!(!needs_cross_origin_isolation("/public/ws/secret", None));
|
||||
assert!(!needs_cross_origin_isolation("/a/ws/my/path", None));
|
||||
assert!(!needs_cross_origin_isolation(
|
||||
"/public/ws/secret",
|
||||
Some("foo=1")
|
||||
));
|
||||
|
||||
// unrelated paths never get the headers
|
||||
assert!(!needs_cross_origin_isolation(
|
||||
"/apps/get/foo",
|
||||
Some("wm_coep")
|
||||
));
|
||||
// `/api/` must not be caught by the `/a/` prefix
|
||||
assert!(!needs_cross_origin_isolation(
|
||||
"/api/version",
|
||||
Some("wm_coep")
|
||||
));
|
||||
assert!(!needs_cross_origin_isolation("/", None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +393,7 @@ pub async fn clone_script<'c>(
|
||||
modules: s.modules,
|
||||
auto_parent: None,
|
||||
labels: s.labels,
|
||||
skip_draft_deletion: None,
|
||||
};
|
||||
|
||||
let new_hash = hash_script(&ns);
|
||||
|
||||
@@ -5387,6 +5387,7 @@ async fn push_inner<'c, 'd>(
|
||||
cache_ttl: cache_ttl.map(|val| val as u32),
|
||||
cache_ignore_s3_path: cache_ignore_s3_path,
|
||||
same_worker: false,
|
||||
preserve_step_tags: false,
|
||||
early_return: None,
|
||||
skip_expr: None,
|
||||
preprocessor_module: None,
|
||||
|
||||
@@ -109,6 +109,11 @@ pub struct NewFlow {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this flow must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
impl NewFlow {
|
||||
@@ -168,6 +173,13 @@ pub struct FlowValue {
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub same_worker: bool,
|
||||
// When the flow runs on a custom worker tag, by default that tag is propagated to
|
||||
// (and overrides) every step, script and nested sub-flow. Set this to true to instead
|
||||
// let steps that declare their own non-empty tag run on it; steps without their own tag
|
||||
// still inherit the flow's tag. Defaults to false to preserve the historical behavior.
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub preserve_step_tags: bool,
|
||||
#[serde(flatten)]
|
||||
pub concurrency_settings: ConcurrencySettings,
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -538,9 +538,19 @@ pub struct NewScript {
|
||||
pub auto_parent: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this script must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted. Deliberately excluded from `impl Hash`
|
||||
/// below (it must not affect the version hash) and from the no-op
|
||||
/// comparison in the deploy handler (it isn't part of what the script
|
||||
/// *is*). See `is_noop_deploy_against_parent`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
|
||||
// IMPORTANT: update this Hash impl when adding fields to NewScript
|
||||
// (exception: caller-intent flags like `skip_draft_deletion` are intentionally
|
||||
// omitted — they must not influence the computed version hash)
|
||||
impl Hash for NewScript {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.path.hash(state);
|
||||
|
||||
@@ -31,6 +31,7 @@ $PY_PATH
|
||||
$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG
|
||||
--system
|
||||
--reinstall
|
||||
--compile-bytecode
|
||||
"
|
||||
|
||||
echo $CMD
|
||||
|
||||
@@ -2095,6 +2095,9 @@ async fn spawn_uv_install(
|
||||
"--no-cache",
|
||||
// If we invoke uv pip install, then we want to overwrite existing data
|
||||
"--reinstall",
|
||||
// Compile .py to .pyc at install time so imports are fast even
|
||||
// through read-only nsjail mounts (no in-memory compilation per job).
|
||||
"--compile-bytecode",
|
||||
];
|
||||
|
||||
if let Some(py_path) = py_path.as_ref() {
|
||||
|
||||
@@ -2985,6 +2985,87 @@ struct PushNextFlowJobRec {
|
||||
|
||||
// #[async_recursion]
|
||||
// #[instrument(level = "trace", skip_all)]
|
||||
/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor).
|
||||
///
|
||||
/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker
|
||||
/// group. The exceptions, in order:
|
||||
/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag,
|
||||
/// always uses the child's own tag (`step_tag`);
|
||||
/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag,
|
||||
/// that tag is honored instead of being overridden by the flow tag;
|
||||
/// - otherwise the child inherits the parent flow job's tag.
|
||||
fn resolve_flow_step_tag(
|
||||
is_preprocessor_step: bool,
|
||||
flow_tag: &str,
|
||||
workspace_id: &str,
|
||||
preserve_step_tags: bool,
|
||||
step_tag: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) {
|
||||
step_tag.map(str::to_string)
|
||||
} else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) {
|
||||
step_tag.map(str::to_string)
|
||||
} else {
|
||||
Some(flow_tag.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tag_resolution_tests {
|
||||
use super::resolve_flow_step_tag;
|
||||
|
||||
#[test]
|
||||
fn step_inherits_custom_flow_tag_by_default() {
|
||||
// Parent flow on a custom tag, step declares its own tag, preserve disabled:
|
||||
// the step inherits the flow tag (historical behavior).
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")),
|
||||
Some("worker-group-A".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_keeps_own_tag_when_preserve_enabled() {
|
||||
// The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a
|
||||
// flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on.
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")),
|
||||
Some("worker-group-B".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() {
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(false, "worker-group-A", "w1", true, None),
|
||||
Some("worker-group-A".to_string())
|
||||
);
|
||||
// An empty tag counts as "no tag" and still inherits.
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")),
|
||||
Some("worker-group-A".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_flow_tag_always_uses_step_tag() {
|
||||
for flow_tag in ["flow", "flow-w1"] {
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")),
|
||||
Some("worker-group-B".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessor_step_uses_step_tag() {
|
||||
assert_eq!(
|
||||
resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")),
|
||||
Some("worker-group-B".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_next_flow_job(
|
||||
flow_job: Arc<MiniPulledJob>,
|
||||
mut status: FlowStatus,
|
||||
@@ -4118,13 +4199,13 @@ async fn push_next_flow_job(
|
||||
.map(|x| x.into());
|
||||
|
||||
tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}");
|
||||
let tag = if step.is_preprocessor_step()
|
||||
|| (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id))
|
||||
{
|
||||
payload_tag.tag.clone()
|
||||
} else {
|
||||
Some(flow_job.tag.clone())
|
||||
};
|
||||
let tag = resolve_flow_step_tag(
|
||||
step.is_preprocessor_step(),
|
||||
&flow_job.tag,
|
||||
&flow_job.workspace_id,
|
||||
flow.preserve_step_tags,
|
||||
payload_tag.tag.as_deref(),
|
||||
);
|
||||
|
||||
let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref()
|
||||
{
|
||||
@@ -4822,6 +4903,7 @@ fn payload_from_modules<'a>(
|
||||
modules_node: Option<FlowNodeId>,
|
||||
failure_module: Option<&Box<FlowModule>>,
|
||||
same_worker: bool,
|
||||
preserve_step_tags: bool,
|
||||
id: impl FnOnce() -> String,
|
||||
path: impl FnOnce() -> String,
|
||||
opt_empty_inner_flows: bool,
|
||||
@@ -4842,7 +4924,13 @@ fn payload_from_modules<'a>(
|
||||
}
|
||||
|
||||
Some(JobPayload::RawFlow {
|
||||
value: FlowValue { modules, failure_module, same_worker, ..Default::default() },
|
||||
value: FlowValue {
|
||||
modules,
|
||||
failure_module,
|
||||
same_worker,
|
||||
preserve_step_tags,
|
||||
..Default::default()
|
||||
},
|
||||
path: Some(path()),
|
||||
restarted_from: None,
|
||||
})
|
||||
@@ -5144,6 +5232,7 @@ async fn compute_next_flow_transform(
|
||||
modules_node,
|
||||
flow.failure_module.as_ref(),
|
||||
flow.same_worker,
|
||||
flow.preserve_step_tags,
|
||||
|| format!("{}-{i}", status.step),
|
||||
|| format!("{}/forloop-{i}", flow_job.runnable_path()),
|
||||
true,
|
||||
@@ -5280,6 +5369,7 @@ async fn compute_next_flow_transform(
|
||||
modules_node,
|
||||
flow.failure_module.as_ref(),
|
||||
flow.same_worker,
|
||||
flow.preserve_step_tags,
|
||||
|| status.step.to_string(),
|
||||
|| format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx),
|
||||
true,
|
||||
@@ -5321,6 +5411,7 @@ async fn compute_next_flow_transform(
|
||||
modules_node,
|
||||
flow.failure_module.as_ref(),
|
||||
flow.same_worker,
|
||||
flow.preserve_step_tags,
|
||||
|| format!("{}-{i}", status.step),
|
||||
|| format!("{}/branchall-{}", flow_job.runnable_path(), i),
|
||||
false,
|
||||
@@ -5391,6 +5482,7 @@ async fn compute_next_flow_transform(
|
||||
modules_node,
|
||||
flow.failure_module.as_ref(),
|
||||
flow.same_worker,
|
||||
flow.preserve_step_tags,
|
||||
|| format!("{}-{}", status.step, branch_status.branch),
|
||||
|| {
|
||||
format!(
|
||||
@@ -5473,6 +5565,7 @@ async fn next_loop_iteration(
|
||||
modules_node,
|
||||
flow.failure_module.as_ref(),
|
||||
flow.same_worker,
|
||||
flow.preserve_step_tags,
|
||||
|| format!("{}-{}", status.step, ns.index),
|
||||
inner_path,
|
||||
true,
|
||||
|
||||
+1
-1
@@ -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.712.0";
|
||||
export const VERSION = "v1.713.1";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -192,6 +192,8 @@ export async function pushApp(
|
||||
deployment_message: message,
|
||||
...localAppBody,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -205,6 +207,8 @@ export async function pushApp(
|
||||
deployment_message: message,
|
||||
...localAppBody,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -480,6 +484,8 @@ const command = new Command()
|
||||
on_behalf_of_email: email,
|
||||
} as any,
|
||||
preserve_on_behalf_of: true,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`));
|
||||
|
||||
@@ -466,6 +466,8 @@ export async function pushRawApp(
|
||||
summary: localApp.summary,
|
||||
policy: appForPolicy.policy,
|
||||
deployment_message: message,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
...(localApp.custom_path
|
||||
? { custom_path: localApp.custom_path }
|
||||
: {}),
|
||||
@@ -486,6 +488,8 @@ export async function pushRawApp(
|
||||
summary: localApp.summary,
|
||||
policy: appForPolicy.policy,
|
||||
deployment_message: message,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
...(localApp.custom_path
|
||||
? { custom_path: localApp.custom_path }
|
||||
: {}),
|
||||
|
||||
@@ -224,6 +224,8 @@ export async function pushFlow(
|
||||
deployment_message: message,
|
||||
...localFlowBody,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -237,6 +239,8 @@ export async function pushFlow(
|
||||
deployment_message: message,
|
||||
...localFlowBody,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1159,6 +1163,8 @@ const command = new Command()
|
||||
path: flowPath,
|
||||
on_behalf_of_email: email,
|
||||
preserve_on_behalf_of: true,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
} as any,
|
||||
});
|
||||
log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`));
|
||||
|
||||
@@ -758,6 +758,9 @@ async function createScript(
|
||||
workspace: Workspace
|
||||
): Promise<number> {
|
||||
const start = performance.now();
|
||||
// Preserve any user draft at this path: a CLI / git-sync deploy must not wipe
|
||||
// an in-progress draft the way a UI "deploy from draft" intentionally does.
|
||||
body = { ...body, skip_draft_deletion: true };
|
||||
// skip_if_noop asks the backend to treat deploys identical to the parent
|
||||
// (same content, lockfile, and metadata) as a no-op, so the CLI does not
|
||||
// produce phantom git-sync / promotion commits on re-pushes.
|
||||
@@ -1796,6 +1799,8 @@ async function setPermissionedAs(
|
||||
parent_hash: remote.hash,
|
||||
on_behalf_of_email: email,
|
||||
preserve_on_behalf_of: true,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
},
|
||||
});
|
||||
log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`));
|
||||
|
||||
+12
-683
File diff suppressed because one or more lines are too long
+1
-1
@@ -89,7 +89,7 @@ export {
|
||||
token,
|
||||
};
|
||||
|
||||
export const VERSION = "1.712.0";
|
||||
export const VERSION = "1.713.1";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
Generated
+32
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.712.0",
|
||||
"version": "1.713.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.712.0",
|
||||
"version": "1.713.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -66,6 +66,7 @@
|
||||
"quill": "^1.3.7",
|
||||
"rehype-github-alerts": "^3.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"runed": "^0.36.0",
|
||||
"svelte-carousel": "^1.0.25",
|
||||
@@ -6352,6 +6353,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-sanitize": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
|
||||
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"unist-util-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-parse5": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
|
||||
@@ -10888,6 +10904,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-sanitize": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
|
||||
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-sanitize": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.712.0",
|
||||
"version": "1.713.1",
|
||||
"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",
|
||||
@@ -126,7 +126,6 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
@@ -141,12 +140,14 @@
|
||||
"quill": "^1.3.7",
|
||||
"rehype-github-alerts": "^3.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"runed": "^0.36.0",
|
||||
"svelte-carousel": "^1.0.25",
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
"vscode-languageclient": "~9.0.1",
|
||||
"vscode-uri": "~3.1.0",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import DropdownV2Inner from './DropdownV2Inner.svelte'
|
||||
import { pointerDownOutside } from '$lib/utils'
|
||||
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -40,7 +41,10 @@
|
||||
size?: ButtonType.UnifiedSize
|
||||
btnText?: string
|
||||
buttonReplacement?: import('svelte').Snippet
|
||||
menu?: import('svelte').Snippet
|
||||
// In customMenu mode the snippet receives the melt-ui `item` action
|
||||
// store so consumers can wrap their own rows in <MenuItem> (or
|
||||
// `use:melt={$item}`) and get arrow-key navigation + aria wiring.
|
||||
menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]>
|
||||
maxHeight?: string | undefined
|
||||
}
|
||||
|
||||
@@ -172,7 +176,7 @@
|
||||
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
|
||||
>
|
||||
{#if customMenu}
|
||||
{@render menu?.()}
|
||||
{@render menu?.({ item, close })}
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
|
||||
@@ -147,6 +147,11 @@
|
||||
preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined
|
||||
// To execute preview scripts with the right worker group
|
||||
customTag?: string
|
||||
// Opt-in: reflect external `code` prop mutations back into Monaco (see
|
||||
// the effect below). One-way `code={...}` callers that need live
|
||||
// external updates — e.g. the inline flow rawscript — set this. Off by
|
||||
// default so every other caller's behavior is unchanged.
|
||||
syncExternalCode?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -178,7 +183,8 @@
|
||||
enablePreprocessorSnippet = false,
|
||||
rawAppRunnableKey = undefined,
|
||||
preparedAssetsSqlQueries,
|
||||
customTag
|
||||
customTag,
|
||||
syncExternalCode = false
|
||||
}: Props = $props()
|
||||
|
||||
$effect.pre(() => {
|
||||
@@ -1829,6 +1835,30 @@
|
||||
$effect(() => {
|
||||
lang = scriptLangToEditorLang(scriptLang)
|
||||
})
|
||||
|
||||
// Opt-in (syncExternalCode): reflect external `code` prop mutations into
|
||||
// Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g.
|
||||
// the inline rawscript in the flow editor — otherwise mutate the prop
|
||||
// without Monaco ever showing the change (the AI chat editing a flow
|
||||
// module's content in a session is the motivating case). Gated off by
|
||||
// default: Editor is sensitive and most callers either bind:code (and
|
||||
// carry their own external-sync) or treat code as init-only, so a blanket
|
||||
// setValue would risk clobbering them. The `getValue() !== code` guard
|
||||
// keeps the caret intact when the change originated from typing inside
|
||||
// Monaco (which round-trips code back via `$bindable`, re-firing this
|
||||
// effect with `code === getValue()`).
|
||||
let lastExternalCodeSync = code
|
||||
$effect(() => {
|
||||
if (!syncExternalCode) return
|
||||
if (code === lastExternalCodeSync) return
|
||||
lastExternalCodeSync = code
|
||||
if (!editor) return
|
||||
untrack(() => {
|
||||
if (editor!.getValue() !== code) {
|
||||
editor!.setValue(code ?? '')
|
||||
}
|
||||
})
|
||||
})
|
||||
$effect(() => {
|
||||
filePath = computePath(path)
|
||||
})
|
||||
|
||||
@@ -6,13 +6,28 @@
|
||||
interface Props {
|
||||
beforeYaml: string
|
||||
afterYaml: string
|
||||
/** Side-by-side vs unified. Leave undefined to let
|
||||
* FlowGraphDiffViewer show its own user-facing toggle (matches the
|
||||
* pre-fork-diff-drawer behavior). */
|
||||
inlineDiff?: boolean
|
||||
/** Forwarded to FlowGraphDiffViewer — render an empty surface
|
||||
* placeholder for the "before" / "after" pane when the item is
|
||||
* added / removed. */
|
||||
beforeMissing?: boolean
|
||||
afterMissing?: boolean
|
||||
}
|
||||
|
||||
let { beforeYaml, afterYaml }: Props = $props()
|
||||
let {
|
||||
beforeYaml,
|
||||
afterYaml,
|
||||
inlineDiff = undefined,
|
||||
beforeMissing = false,
|
||||
afterMissing = false
|
||||
}: Props = $props()
|
||||
let diffMode: 'yaml' | 'graph' = $state('graph')
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-[500px] gap-2">
|
||||
<div class="flex flex-col h-full min-h-[500px]">
|
||||
<Tabs bind:selected={diffMode}>
|
||||
<Tab value="graph" label="Graph" />
|
||||
<Tab value="yaml" label="YAML" />
|
||||
@@ -30,6 +45,7 @@
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={beforeYaml}
|
||||
defaultModified={afterYaml}
|
||||
{inlineDiff}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
@@ -37,7 +53,7 @@
|
||||
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default {beforeYaml} {afterYaml} />
|
||||
<Module.default {beforeYaml} {afterYaml} {beforeMissing} {afterMissing} {inlineDiff} />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
import YAML from 'yaml'
|
||||
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
|
||||
import { Alert, Button } from './common'
|
||||
import { Alert } from './common'
|
||||
import { computeFlowModuleDiff } from './flows/flowDiff'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
|
||||
import type { Viewport } from '@xyflow/svelte'
|
||||
|
||||
const SIDE_BY_SIDE_MIN_WIDTH = 700
|
||||
@@ -15,13 +15,54 @@
|
||||
interface Props {
|
||||
beforeYaml: string
|
||||
afterYaml: string
|
||||
/** When true, render an empty surface placeholder for the "before"
|
||||
* pane in side-by-side mode (use for added items where there's no
|
||||
* prior flow to show). */
|
||||
beforeMissing?: boolean
|
||||
/** Same as `beforeMissing` but for the "after" pane (use for removed
|
||||
* items). */
|
||||
afterMissing?: boolean
|
||||
/** Render the unified single-pane diff when true, side-by-side
|
||||
* otherwise. When undefined, the component renders its own
|
||||
* Unified / Side-by-side toggle in the corner (legacy behavior for
|
||||
* the standalone comparison page). A narrow viewer still falls back
|
||||
* to unified automatically. */
|
||||
inlineDiff?: boolean | undefined
|
||||
}
|
||||
|
||||
let { beforeYaml, afterYaml }: Props = $props()
|
||||
let {
|
||||
beforeYaml,
|
||||
afterYaml,
|
||||
beforeMissing = false,
|
||||
afterMissing = false,
|
||||
inlineDiff = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// Local toggle state, used only when no inlineDiff prop is supplied.
|
||||
let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside')
|
||||
const showLocalToggle = $derived(inlineDiff === undefined)
|
||||
const effectiveInlineDiff = $derived(
|
||||
inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified'
|
||||
)
|
||||
|
||||
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
|
||||
let beforePaneSize = $state(50)
|
||||
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
|
||||
// Track the content area's rendered height so unified-mode graphs can
|
||||
// grow to fill the diff box (otherwise FlowGraphV2 sits at its
|
||||
// content-fit height + small floor, leaving empty space below).
|
||||
let contentAreaHeight = $state(0)
|
||||
|
||||
// Each FlowGraphV2 sizes itself to its own content (clamped to minHeight).
|
||||
// In side-by-side mode we want both graphs to share the same height, so
|
||||
// we track each side's reported height and feed back the max as minHeight
|
||||
// to both. The width-graph then stays at its computed size; the shorter
|
||||
// graph grows to match.
|
||||
let beforeContentHeight = $state(0)
|
||||
let afterContentHeight = $state(0)
|
||||
const SHARED_MIN_HEIGHT = 400
|
||||
const sharedMinHeight = $derived(
|
||||
Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight)
|
||||
)
|
||||
|
||||
// Shared viewport for synchronizing both graphs in side-by-side mode
|
||||
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
|
||||
@@ -29,7 +70,10 @@
|
||||
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
|
||||
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
|
||||
|
||||
function parseFlow(yaml: string, label: 'before' | 'after'): {
|
||||
function parseFlow(
|
||||
yaml: string,
|
||||
label: 'before' | 'after'
|
||||
): {
|
||||
flow: OpenFlow | undefined
|
||||
error: string | undefined
|
||||
} {
|
||||
@@ -49,14 +93,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before'))
|
||||
let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after'))
|
||||
// For added/removed items, the caller passes empty YAML and sets the
|
||||
// corresponding *Missing flag. We swap in an empty OpenFlow stub on
|
||||
// that side so the unified diff path still has something to compare
|
||||
// against (every module on the present side becomes added / removed).
|
||||
// The side-by-side rendering uses the flag directly to draw a
|
||||
// placeholder pane instead.
|
||||
const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } }
|
||||
|
||||
let beforeParsed = $derived.by(() =>
|
||||
beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before')
|
||||
)
|
||||
let afterParsed = $derived.by(() =>
|
||||
afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after')
|
||||
)
|
||||
let parseError = $derived(beforeParsed.error ?? afterParsed.error)
|
||||
let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow)
|
||||
let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow)
|
||||
|
||||
// Determine if we should render side-by-side or unified (user controlled via toggle)
|
||||
let isSideBySide = $derived(viewMode === 'sidebyside')
|
||||
// Side-by-side unless the caller asked for unified, OR the viewer pane
|
||||
// is too narrow to comfortably split (fallback to unified for legibility).
|
||||
const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH)
|
||||
|
||||
// Build timeline using history-based approach
|
||||
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
|
||||
@@ -72,14 +129,6 @@
|
||||
sharedViewport = viewport
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
|
||||
viewMode = 'unified'
|
||||
} else {
|
||||
viewMode = 'sidebyside'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if parseError}
|
||||
@@ -88,10 +137,12 @@
|
||||
</Alert>
|
||||
{:else if beforeFlow && afterFlow}
|
||||
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
|
||||
<!-- Header with view toggle -->
|
||||
<div class="flex flex-row items-center justify-end m-2 gap-4">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={viewMode}>
|
||||
{#if showLocalToggle}
|
||||
<!-- Legacy top banner — only holds the local Unified / Side-by-side
|
||||
toggle when the parent doesn't pre-set inlineDiff. Zoom
|
||||
controls live as an overlay below (same as the controlled path). -->
|
||||
<div class="flex flex-row items-center justify-end m-2">
|
||||
<ToggleButtonGroup bind:selected={localViewMode} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
|
||||
<ToggleButton
|
||||
@@ -103,102 +154,131 @@
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<!-- Header with controls and view toggle -->
|
||||
{/if}
|
||||
<!-- Main content area -->
|
||||
<div class="flex-1 overflow-hidden relative" bind:clientHeight={contentAreaHeight}>
|
||||
{#if isSideBySide}
|
||||
<!-- Shared controls for both graphs in side-by-side mode -->
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
<!-- Shared zoom controls overlay the graph viewport. Uses xy-flow's
|
||||
own `.svelte-flow__controls` / `.svelte-flow__controls-button`
|
||||
classes so it inherits the same look as FlowGraphV2's built-in
|
||||
controls (FlowGraphV2's global override gives the buttons
|
||||
bg-surface + hover bg-surface-hover with border:0). Local
|
||||
overrides bump the icon size from 12px to 16px and drop the
|
||||
xy-flow default shadow. No fit-view button — recenter can't
|
||||
sync across two graphs. -->
|
||||
<div
|
||||
class="svelte-flow__controls horizontal absolute top-[15px] right-[15px] z-10 rounded bg-surface border border-gray-200 dark:border-gray-700 overflow-hidden diff-zoom-controls"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
class="svelte-flow__controls-button"
|
||||
onclick={() => {
|
||||
beforeGraph?.zoomIn()
|
||||
afterGraph?.zoomIn()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Plus }}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
class="svelte-flow__controls-button"
|
||||
onclick={() => {
|
||||
beforeGraph?.zoomOut()
|
||||
afterGraph?.zoomOut()
|
||||
}}
|
||||
iconOnly
|
||||
startIcon={{ icon: Minus }}
|
||||
/>
|
||||
>
|
||||
<Minus />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{#if isSideBySide}
|
||||
<!-- Side-by-side view for wide screens -->
|
||||
<Splitpanes class="!overflow-visible h-full">
|
||||
<!-- Before (Left) -->
|
||||
<Pane bind:size={beforePaneSize} minSize={30}>
|
||||
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<FlowGraphV2
|
||||
bind:this={beforeGraph}
|
||||
modules={beforeFlow.value.modules}
|
||||
groups={beforeFlow.value.groups}
|
||||
failureModule={beforeFlow.value.failure_module}
|
||||
preprocessorModule={beforeFlow.value.preprocessor_module}
|
||||
earlyStop={beforeFlow.value.skip_expr !== undefined}
|
||||
cache={beforeFlow.value.cache_ttl !== undefined}
|
||||
moduleActions={beforeActions}
|
||||
notSelectable={true}
|
||||
insertable={false}
|
||||
editMode={false}
|
||||
download={false}
|
||||
scroll={false}
|
||||
minHeight={400}
|
||||
triggerNode={false}
|
||||
{sharedViewport}
|
||||
onViewportChange={handleViewportChange}
|
||||
>
|
||||
{#snippet leftHeader()}
|
||||
<span class="text-sm text-primary">Before</span>
|
||||
{/snippet}
|
||||
</FlowGraphV2>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700 relative bg-surface-secondary {beforeMissing
|
||||
? 'hatched-thin'
|
||||
: ''}"
|
||||
>
|
||||
{#if beforeMissing}
|
||||
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
|
||||
Before <span class="italic">(no prior version)</span>
|
||||
</span>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<FlowGraphV2
|
||||
bind:this={beforeGraph}
|
||||
modules={beforeFlow.value.modules}
|
||||
groups={beforeFlow.value.groups}
|
||||
failureModule={beforeFlow.value.failure_module}
|
||||
preprocessorModule={beforeFlow.value.preprocessor_module}
|
||||
earlyStop={beforeFlow.value.skip_expr !== undefined}
|
||||
cache={beforeFlow.value.cache_ttl !== undefined}
|
||||
moduleActions={beforeActions}
|
||||
notSelectable={true}
|
||||
insertable={false}
|
||||
editMode={false}
|
||||
download={false}
|
||||
scroll={false}
|
||||
minHeight={sharedMinHeight}
|
||||
triggerNode={false}
|
||||
{sharedViewport}
|
||||
onViewportChange={handleViewportChange}
|
||||
onHeight={(h) => (beforeContentHeight = h)}
|
||||
>
|
||||
{#snippet leftHeader()}
|
||||
<span class="text-2xs text-tertiary">Before</span>
|
||||
{/snippet}
|
||||
</FlowGraphV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- After (Right) - Show merged flow with shadowed removed modules -->
|
||||
<Pane minSize={30} class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<FlowGraphV2
|
||||
bind:this={afterGraph}
|
||||
diffBeforeFlow={beforeFlow}
|
||||
modules={afterFlow.value.modules}
|
||||
groups={afterFlow.value.groups}
|
||||
failureModule={afterFlow.value.failure_module}
|
||||
preprocessorModule={afterFlow.value.preprocessor_module}
|
||||
earlyStop={afterFlow.value.skip_expr !== undefined}
|
||||
cache={afterFlow.value.cache_ttl !== undefined}
|
||||
currentInputSchema={afterFlow.schema}
|
||||
markRemovedAsShadowed={true}
|
||||
notSelectable={true}
|
||||
insertable={false}
|
||||
editMode={false}
|
||||
download={false}
|
||||
scroll={false}
|
||||
minHeight={400}
|
||||
triggerNode={false}
|
||||
{sharedViewport}
|
||||
onViewportChange={handleViewportChange}
|
||||
>
|
||||
{#snippet leftHeader()}
|
||||
<span class="text-sm text-primary">After</span>
|
||||
{/snippet}
|
||||
</FlowGraphV2>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-full relative bg-surface-secondary {afterMissing
|
||||
? 'hatched-thin'
|
||||
: ''}"
|
||||
>
|
||||
{#if afterMissing}
|
||||
<span class="absolute top-2 left-2 z-10 text-2xs text-tertiary">
|
||||
After <span class="italic">(flow deleted)</span>
|
||||
</span>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<FlowGraphV2
|
||||
bind:this={afterGraph}
|
||||
diffBeforeFlow={beforeFlow}
|
||||
modules={afterFlow.value.modules}
|
||||
groups={afterFlow.value.groups}
|
||||
failureModule={afterFlow.value.failure_module}
|
||||
preprocessorModule={afterFlow.value.preprocessor_module}
|
||||
earlyStop={afterFlow.value.skip_expr !== undefined}
|
||||
cache={afterFlow.value.cache_ttl !== undefined}
|
||||
currentInputSchema={afterFlow.schema}
|
||||
markRemovedAsShadowed={true}
|
||||
notSelectable={true}
|
||||
insertable={false}
|
||||
editMode={false}
|
||||
download={false}
|
||||
scroll={false}
|
||||
minHeight={sharedMinHeight}
|
||||
triggerNode={false}
|
||||
{sharedViewport}
|
||||
onViewportChange={handleViewportChange}
|
||||
onHeight={(h) => (afterContentHeight = h)}
|
||||
>
|
||||
{#snippet leftHeader()}
|
||||
<span class="text-2xs text-tertiary">After</span>
|
||||
{/snippet}
|
||||
</FlowGraphV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
@@ -219,7 +299,7 @@
|
||||
editMode={false}
|
||||
download={false}
|
||||
scroll={false}
|
||||
minHeight={400}
|
||||
minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)}
|
||||
triggerNode={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -231,3 +311,31 @@
|
||||
<p class="text-gray-500">Loading graphs...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Thin diagonal hatch used as the empty-pane fill. */
|
||||
.hatched-thin {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 6px,
|
||||
rgba(128, 128, 128, 0.16) 6px,
|
||||
rgba(128, 128, 128, 0.16) 7.5px
|
||||
);
|
||||
}
|
||||
|
||||
/* Shared zoom controls overlay: same xy-flow layout as the in-graph
|
||||
controls (`.svelte-flow__controls.horizontal`) but with bigger Plus/Minus
|
||||
glyphs (xy-flow caps svg at 12px by default) and no panel shadow. */
|
||||
.diff-zoom-controls {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.diff-zoom-controls :global(.svelte-flow__controls-button) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.diff-zoom-controls :global(.svelte-flow__controls-button svg) {
|
||||
max-width: 16px;
|
||||
max-height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Markdown, type Plugin } from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { rehypeGithubAlerts } from 'rehype-github-alerts'
|
||||
import { Markdown } from 'svelte-exmarkdown'
|
||||
import { markdownPlugins as plugins } from './markdownPlugins'
|
||||
interface Props {
|
||||
md: string
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
let { md, noPadding }: Props = $props()
|
||||
const plugins: Plugin[] = [
|
||||
gfmPlugin(),
|
||||
{ rehypePlugin: [rehypeRaw] },
|
||||
{ rehypePlugin: [rehypeGithubAlerts] }
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import { classes } from '$lib/components/common/alert/model'
|
||||
import { type Value } from '$lib/utils'
|
||||
import { AlertCircle } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
interface Props {
|
||||
/** Whether there are unsaved local changes relative to the deployed baseline. */
|
||||
show: boolean
|
||||
/** Deployed/backend baseline config (the "original" side of the diff). */
|
||||
getDeployed: () => unknown
|
||||
/** Current form config — the local autosave (the "current" side of the diff). */
|
||||
getCurrent: () => unknown
|
||||
/** Drop the local changes and reset the form to the deployed baseline. */
|
||||
onDiscard: () => void | Promise<void>
|
||||
/** When true (e.g. no write access), hide the Discard action. */
|
||||
disabled?: boolean
|
||||
/** Diff drawer title. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
let {
|
||||
show,
|
||||
getDeployed,
|
||||
getCurrent,
|
||||
onDiscard,
|
||||
disabled = false,
|
||||
title = 'Deployed <> Local changes'
|
||||
}: Props = $props()
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
function showDiff() {
|
||||
const deployed = getDeployed()
|
||||
if (deployed == null) return
|
||||
// Snapshot both sides at click time. They are typically Svelte `$state`
|
||||
// proxies (resource/variable's `initialStates[ws]` and the draft handle's
|
||||
// cell); without snapshot the diff drawer would re-read them reactively
|
||||
// and update as the user keeps typing behind it.
|
||||
const original = $state.snapshot(deployed) as Value
|
||||
const current = $state.snapshot(getCurrent()) as Value
|
||||
diffDrawer?.openDrawer()
|
||||
// Mirror the inline Discard's `disabled` gate inside the diff drawer —
|
||||
// otherwise a read-only user could still trigger onDiscard via the
|
||||
// drawer button even though we hid the banner's inline action.
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original,
|
||||
current,
|
||||
title,
|
||||
button: disabled
|
||||
? undefined
|
||||
: {
|
||||
text: 'Discard changes',
|
||||
onClick: async () => {
|
||||
await onDiscard()
|
||||
diffDrawer?.closeDrawer()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} />
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
transition:slide|local={{ duration: 120 }}
|
||||
class={twMerge(
|
||||
'flex flex-row items-center justify-between gap-2 px-4 py-1',
|
||||
classes.warning.bgClass,
|
||||
'!border-0 !rounded-none'
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 min-w-0">
|
||||
<AlertCircle class={classes.warning.iconClass} size={16} />
|
||||
<span class={twMerge('text-xs font-semibold truncate', classes.warning.titleClass)}>
|
||||
You have unsaved changes
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
btnClasses={classes.warning.titleClass}
|
||||
on:click={showDiff}>Show diff</Button
|
||||
>
|
||||
{#if !disabled}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
btnClasses={classes.warning.titleClass}
|
||||
on:click={onDiscard}>Discard</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,7 +14,6 @@
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +25,14 @@
|
||||
defaultValues?: Record<string, any> | undefined
|
||||
workspace?: string | undefined
|
||||
selected?: string | undefined
|
||||
/** Notifies the parent drawer whether a local draft for the selected
|
||||
* workspace diverges from the deployed baseline, so it can show the
|
||||
* "unsaved changes" banner below its header. */
|
||||
onDraftStateChange?: (hasDraft: boolean) => void
|
||||
/** Notifies the parent drawer of write-access for the selected workspace,
|
||||
* so it can hide the banner's Discard button in read-only mode (matches
|
||||
* the trigger editors' `disabled={!can_write}` wiring). */
|
||||
onCanWriteChange?: (canWrite: boolean) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -36,7 +43,9 @@
|
||||
onChange,
|
||||
defaultValues = undefined,
|
||||
workspace = undefined,
|
||||
selected: selectedProp = $bindable()
|
||||
selected: selectedProp = $bindable(),
|
||||
onDraftStateChange,
|
||||
onCanWriteChange
|
||||
}: Props = $props()
|
||||
|
||||
type ResourceState = {
|
||||
@@ -189,6 +198,11 @@
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
// Banner is scoped to the selected workspace — the diff/discard only
|
||||
// operate on it, so showing it for an unrelated dirty workspace would be
|
||||
// misleading. The cross-workspace `otherDirty` alert below still covers
|
||||
// that case.
|
||||
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
|
||||
const otherDirty = $derived(
|
||||
dirtyWorkspaces.length == 1
|
||||
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
|
||||
@@ -277,11 +291,6 @@
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToDeployed: () => {
|
||||
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
@@ -330,6 +339,29 @@
|
||||
canSave = anyDirty && dirtyValid && dirtyCanWrite
|
||||
})
|
||||
|
||||
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
|
||||
// (header + banner slot) lives in ResourceEditorDrawer, above this
|
||||
// lazily-imported content, so the state is lifted up via these accessors.
|
||||
$effect(() => {
|
||||
onDraftStateChange?.(!!initialPath && selectedDirty)
|
||||
})
|
||||
$effect(() => {
|
||||
onCanWriteChange?.(can_write)
|
||||
})
|
||||
|
||||
export function localDraftDeployed(): ResourceState | undefined {
|
||||
return selected ? initialStates[selected] : undefined
|
||||
}
|
||||
export function localDraftCurrent(): ResourceState | undefined {
|
||||
return current
|
||||
}
|
||||
export function discardLocalDraft(): void {
|
||||
if (!selected) return
|
||||
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (current)
|
||||
// $state.snapshot deep-reads (so the effect re-runs on nested
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Loader2, Save } from 'lucide-svelte'
|
||||
import WsSpecificVersions from './WsSpecificVersions.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
|
||||
let {
|
||||
workspace = undefined,
|
||||
@@ -17,7 +18,16 @@
|
||||
let resource_type: string | undefined = $state(undefined)
|
||||
let defaultValues: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
let resourceEditor: { save: () => void } | undefined = $state(undefined)
|
||||
let resourceEditor:
|
||||
| {
|
||||
save: () => void
|
||||
localDraftDeployed: () => unknown
|
||||
localDraftCurrent: () => unknown
|
||||
discardLocalDraft: () => void
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
let hasLocalDraft = $state(false)
|
||||
let canWriteSelected = $state(true)
|
||||
|
||||
let path: string | undefined = $state(undefined)
|
||||
let selected: string | undefined = $state(undefined)
|
||||
@@ -62,8 +72,19 @@
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
bind:selected
|
||||
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
||||
onCanWriteChange={(v) => (canWriteSelected = v)}
|
||||
/>
|
||||
{/await}
|
||||
{#snippet banner()}
|
||||
<LocalDraftBanner
|
||||
show={hasLocalDraft}
|
||||
getDeployed={() => resourceEditor?.localDraftDeployed()}
|
||||
getCurrent={() => resourceEditor?.localDraftCurrent()}
|
||||
onDiscard={() => resourceEditor?.discardLocalDraft()}
|
||||
disabled={!canWriteSelected}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if mode == 'edit' && path && effectiveWorkspace}
|
||||
<WsSpecificVersions
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
|
||||
import DefaultScripts from './DefaultScripts.svelte'
|
||||
import { onMount, setContext, untrack } from 'svelte'
|
||||
import { getContext, onMount, setContext, untrack } from 'svelte'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import LabelsInput from './LabelsInput.svelte'
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
onDeployError,
|
||||
onSeeDetails,
|
||||
onNavigate,
|
||||
disableAi
|
||||
disableAi,
|
||||
initialTestPanelCollapsed = false,
|
||||
initialPathChosen = false
|
||||
}: ScriptBuilderProps = $props()
|
||||
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
@@ -598,17 +600,23 @@
|
||||
if (!disableHistoryChange) {
|
||||
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
|
||||
}
|
||||
if (
|
||||
// "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the
|
||||
// editor in place rather than navigating to the deployed item.
|
||||
const stayHere =
|
||||
stay ||
|
||||
(script.auto_kind === 'lib' &&
|
||||
script.kind !== 'preprocessor' &&
|
||||
!isWorkflowAsCode(script.content, script.language))
|
||||
) {
|
||||
if (stayHere) {
|
||||
// Re-pin parent_hash so the next deploy's conflict check is against
|
||||
// the version we just wrote.
|
||||
script.parent_hash = newHash
|
||||
sendUserToast('Deployed')
|
||||
} else {
|
||||
onDeploy?.({ path: script.path, hash: newHash })
|
||||
}
|
||||
// Always notify on a successful deploy; the consumer decides whether to
|
||||
// navigate (route) or stay + sync the preview (session). Previously the
|
||||
// stay/lib branch skipped onDeploy, so session previews didn't sync after
|
||||
// a "Deploy & Stay here" or lib-script deploy.
|
||||
onDeploy?.({ path: script.path, hash: newHash, stay: stayHere })
|
||||
} catch (error) {
|
||||
onDeployError?.({ path: script.path, error })
|
||||
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
|
||||
@@ -619,6 +627,12 @@
|
||||
// No-op: persistence happens via the page-level UserDraft autosave.
|
||||
function saveDraft(): void {}
|
||||
|
||||
// Inside an AI session pane (which injects an aiChatManager via context) the
|
||||
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
|
||||
// fork, Exit & See details, Export — don't make sense: the session always
|
||||
// stays put and is already scoped to a fork. Only "Show diff" is kept.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
function computeDropdownItems(
|
||||
initialPath: string,
|
||||
savedScript: Script | NewScript | undefined,
|
||||
@@ -627,26 +641,30 @@
|
||||
let dropdownItems: { label: string; onClick: () => void }[] =
|
||||
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
|
||||
? [
|
||||
{
|
||||
label: 'Deploy & Stay here',
|
||||
onClick: () => {
|
||||
handleEditScript(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fork',
|
||||
onClick: () => {
|
||||
window.open(`/scripts/add?template=${initialPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
|
||||
...(!inSessionPane
|
||||
? [
|
||||
{
|
||||
label: 'Edit in workspace fork',
|
||||
label: 'Deploy & Stay here',
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('script', initialPath))
|
||||
handleEditScript(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fork',
|
||||
onClick: () => {
|
||||
window.open(`/scripts/add?template=${initialPath}`)
|
||||
}
|
||||
},
|
||||
...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking')
|
||||
? [
|
||||
{
|
||||
label: 'Edit in workspace fork',
|
||||
onClick: () => {
|
||||
window.open(buildForkEditUrl('script', initialPath))
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []),
|
||||
...(customUi?.topBar?.diff !== false && savedScript && diffDrawer
|
||||
@@ -678,7 +696,10 @@
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!script.draft_only && script.kind === 'script' && !script.auto_kind
|
||||
...(!inSessionPane &&
|
||||
!script.draft_only &&
|
||||
script.kind === 'script' &&
|
||||
!script.auto_kind
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
@@ -688,7 +709,7 @@
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(isWorkflowAsCode(script.content, script.language)
|
||||
...(!inSessionPane && isWorkflowAsCode(script.content, script.language)
|
||||
? [
|
||||
{
|
||||
label: 'Export as YAML/JSON',
|
||||
@@ -701,7 +722,11 @@
|
||||
]
|
||||
: []
|
||||
|
||||
if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) {
|
||||
if (
|
||||
!inSessionPane &&
|
||||
dropdownItems.length === 0 &&
|
||||
isWorkflowAsCode(script.content, script.language)
|
||||
) {
|
||||
dropdownItems = [
|
||||
{
|
||||
label: 'Export as YAML/JSON',
|
||||
@@ -727,7 +752,11 @@
|
||||
}
|
||||
|
||||
let path: Path | undefined = $state(undefined)
|
||||
let dirtyPath = $state(false)
|
||||
// Seed "path is already chosen" so the summary→path auto-slug (which only
|
||||
// runs for new scripts with initialPath == '') doesn't clobber a path the
|
||||
// caller pre-assigned. The session preview opens AI-created scripts as new
|
||||
// (empty initialPath) but with a path the AI already picked.
|
||||
let dirtyPath = $state(initialPathChosen)
|
||||
|
||||
let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state(
|
||||
(() => {
|
||||
@@ -1896,6 +1925,7 @@
|
||||
bind:assets={script.assets}
|
||||
bind:modules={script.modules}
|
||||
enablePreprocessorSnippet
|
||||
{initialTestPanelCollapsed}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -160,6 +160,11 @@
|
||||
modules?: { [key: string]: ScriptModule } | null
|
||||
editorBarRight?: import('svelte').Snippet
|
||||
enablePreprocessorSnippet?: boolean
|
||||
// When true the right-hand test/run pane mounts collapsed. The user
|
||||
// can still expand it via `toggleTestPanel`. Defaults to false so the
|
||||
// regular /scripts/edit route keeps its current open-by-default UX;
|
||||
// the session preview opts in to save vertical real estate.
|
||||
initialTestPanelCollapsed?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -193,7 +198,8 @@
|
||||
assets = $bindable(),
|
||||
modules = $bindable(undefined),
|
||||
editorBarRight,
|
||||
enablePreprocessorSnippet = false
|
||||
enablePreprocessorSnippet = false,
|
||||
initialTestPanelCollapsed = false
|
||||
}: Props = $props()
|
||||
|
||||
let initialArgs = structuredClone($state.snapshot(args))
|
||||
@@ -1360,8 +1366,11 @@
|
||||
// dynamic minimum below — so when the editor shrinks, the displayed test
|
||||
// pane grows to honor the new minimum without needing an effect. The code
|
||||
// pane's size is purely derived from it (100 - test).
|
||||
let rawTestPanelSize = $state(30)
|
||||
let storedTestPanelSize = untrack(() => rawTestPanelSize)
|
||||
// `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while
|
||||
// keeping the "remembered" size at 30, so the user's first toggle expands
|
||||
// the pane to a sensible width rather than 0.
|
||||
let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30)))
|
||||
let storedTestPanelSize = 30
|
||||
const testPanelSize = $derived(
|
||||
rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent)
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -128,6 +128,11 @@
|
||||
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
// Banner is scoped to the selected workspace — the diff/discard only
|
||||
// operate on it, so showing it for an unrelated dirty workspace would be
|
||||
// misleading. The cross-workspace `otherDirty` alert below still covers
|
||||
// that case.
|
||||
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
|
||||
const otherDirty = $derived(
|
||||
dirtyWorkspaces.length == 1
|
||||
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
|
||||
@@ -191,11 +196,6 @@
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToDeployed: () => {
|
||||
UserDraft.discard('variable', p, s, { workspace: ws })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
@@ -335,6 +335,20 @@
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
>
|
||||
{#snippet banner()}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
getDeployed={() => (selected ? initialStates[selected] : undefined)}
|
||||
getCurrent={() => current}
|
||||
onDiscard={() => {
|
||||
if (!selected) return
|
||||
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
}}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
{/snippet}
|
||||
<div class="flex flex-col gap-8">
|
||||
{#if !can_write}
|
||||
<Alert type="warning" title="Only read access">
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
disabled={edit && $userStore?.operator}
|
||||
/>
|
||||
{#if variable.is_secret}
|
||||
<Alert type="warning" title="Audit log for each access">
|
||||
<Alert type="info" title="Audit log for each access">
|
||||
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
|
||||
addition, any read of a secret variable generates an audit log whose operation name is:
|
||||
variables.decrypt_secret
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
@component
|
||||
Inline diff renderer for a single workspace item. Mirrors the per-kind
|
||||
rendering that DiffDrawer does in its body (`DiffDrawer.svelte:181-271`):
|
||||
|
||||
- `flow` → `<FlowDiffViewer>` (its own Graph / YAML toggle inside)
|
||||
- has `content` (scripts) → Tabs(Content | Metadata) with two Monaco diffs
|
||||
- everything else (apps, resources, variables, schedules, triggers…) →
|
||||
a single Monaco YAML diff over the metadata
|
||||
|
||||
`inlineDiff` flips Monaco's `renderSideBySide` to false (unified view).
|
||||
The component is content-sized — each Monaco block is sized to fit its
|
||||
diff text (no internal scroll) using `lines * 19 + 24`; for the
|
||||
Content+Metadata case we use the max of the two so switching tabs
|
||||
doesn't reflow the parent.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import Tab from './common/tabs/Tab.svelte'
|
||||
import FlowDiffViewer from './FlowDiffViewer.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { cleanValueProperties, orderedYamlStringify, replaceFalseWithUndefined } from '$lib/utils'
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
|
||||
interface Props {
|
||||
/** Any WorkspaceItemDiff['kind'] — used only to special-case `flow`. */
|
||||
kind: string
|
||||
/** Raw value from `getItemValue(kind, path, parentWorkspace)`. Undefined
|
||||
* for "added" items (don't exist in the parent). */
|
||||
originalRaw?: unknown
|
||||
/** Raw value from `getItemValue(kind, path, forkWorkspace)`. Undefined
|
||||
* for "removed" items (don't exist in the fork). */
|
||||
currentRaw?: unknown
|
||||
/** Force unified diff (Monaco renderSideBySide=false). Default false. */
|
||||
inlineDiff?: boolean
|
||||
}
|
||||
|
||||
let { kind, originalRaw, currentRaw, inlineDiff = false }: Props = $props()
|
||||
|
||||
type Prepared = { lang?: string; content?: string; metadata: string }
|
||||
|
||||
function prepareValue(raw: unknown): Prepared {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { metadata: raw == null ? '' : String(raw) }
|
||||
}
|
||||
const cleaned = structuredClone(
|
||||
cleanValueProperties(replaceFalseWithUndefined(raw as Record<string, unknown>))
|
||||
)
|
||||
const content = (cleaned as Record<string, unknown>)['content']
|
||||
if (content !== undefined) {
|
||||
delete (cleaned as Record<string, unknown>)['content']
|
||||
}
|
||||
const language = (raw as Record<string, unknown>).language
|
||||
return {
|
||||
lang:
|
||||
typeof language === 'string'
|
||||
? scriptLangToEditorLang(language as Parameters<typeof scriptLangToEditorLang>[0])
|
||||
: undefined,
|
||||
content: typeof content === 'string' ? content : undefined,
|
||||
metadata: orderedYamlStringify(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
const original = $derived(prepareValue(originalRaw))
|
||||
const current = $derived(prepareValue(currentRaw))
|
||||
const hasContent = $derived(original.content !== undefined || current.content !== undefined)
|
||||
|
||||
// For added / removed flows, the missing side feeds an empty YAML so
|
||||
// the YAML-mode editor shows the whole new (or removed) flow as a
|
||||
// single-sided diff. FlowGraphDiffViewer uses the *Missing flag to
|
||||
// swap in its own OpenFlow stub for parsing and to draw a placeholder
|
||||
// pane in side-by-side mode.
|
||||
const beforeFlowYaml = $derived(originalRaw == null ? '' : original.metadata)
|
||||
const afterFlowYaml = $derived(currentRaw == null ? '' : current.metadata)
|
||||
|
||||
let contentTab: 'content' | 'metadata' = $state('content')
|
||||
|
||||
// Per-tab height: each Monaco block sizes to its own content. Switching
|
||||
// tabs reflows the row, which is the expected tab behavior; we don't
|
||||
// over-allocate to the larger tab the way the previous max() did.
|
||||
const LINE_HEIGHT = 19
|
||||
const EDITOR_CHROME = 24
|
||||
function linesIn(s?: string): number {
|
||||
return Math.max((s ?? '').split('\n').length, 1)
|
||||
}
|
||||
const contentHeight = $derived(
|
||||
`${Math.max(linesIn(original.content), linesIn(current.content)) * LINE_HEIGHT + EDITOR_CHROME}px`
|
||||
)
|
||||
const metadataHeight = $derived(
|
||||
`${Math.max(linesIn(original.metadata), linesIn(current.metadata)) * LINE_HEIGHT + EDITOR_CHROME}px`
|
||||
)
|
||||
const activeTabHeight = $derived(contentTab === 'content' ? contentHeight : metadataHeight)
|
||||
</script>
|
||||
|
||||
{#if kind === 'flow'}
|
||||
<div class="h-[600px]">
|
||||
<FlowDiffViewer
|
||||
beforeYaml={beforeFlowYaml}
|
||||
afterYaml={afterFlowYaml}
|
||||
beforeMissing={originalRaw == null}
|
||||
afterMissing={currentRaw == null}
|
||||
{inlineDiff}
|
||||
/>
|
||||
</div>
|
||||
{:else if hasContent}
|
||||
<div class="flex flex-col">
|
||||
<Tabs bind:selected={contentTab}>
|
||||
<Tab value="content" label="Content" />
|
||||
<Tab value="metadata" label="Metadata" />
|
||||
</Tabs>
|
||||
<div style="height: {activeTabHeight}">
|
||||
{#if contentTab === 'content'}
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
|
||||
{:then Module}
|
||||
<Module.default
|
||||
open={true}
|
||||
automaticLayout
|
||||
className="h-full"
|
||||
defaultLang={original.lang ?? current.lang}
|
||||
defaultOriginal={original.content ?? ''}
|
||||
defaultModified={current.content ?? ''}
|
||||
{inlineDiff}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
|
||||
{:then Module}
|
||||
<Module.default
|
||||
open={true}
|
||||
automaticLayout
|
||||
className="h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={original.metadata}
|
||||
defaultModified={current.metadata}
|
||||
{inlineDiff}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#await import('$lib/components/DiffEditor.svelte')}
|
||||
<div class="p-3"><Loader2 class="w-3.5 h-3.5 animate-spin" /></div>
|
||||
{:then Module}
|
||||
<div style="height: {metadataHeight}">
|
||||
<Module.default
|
||||
open={true}
|
||||
automaticLayout
|
||||
className="h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={original.metadata}
|
||||
defaultModified={current.metadata}
|
||||
{inlineDiff}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemKind
|
||||
} from './workspacePicker'
|
||||
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
|
||||
type Kind = WorkspaceItemKind
|
||||
type Item = WorkspaceItem
|
||||
@@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
// Sibling-popover open: melt-ui's `openFocus` runs once during the close→open
|
||||
// transition; the picker may not be mounted yet. Retry after settle.
|
||||
// Also kicks off the initial scope's fetch — drill/goUp do the same from
|
||||
// their respective branches, so `ensureLoaded` is always a callback
|
||||
// reaction to user navigation, never a reactive consequence.
|
||||
onMount(() => {
|
||||
const t = setTimeout(focus, 50)
|
||||
const initial = untrack(() => scope)
|
||||
if (initial) {
|
||||
if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k)
|
||||
else ensureLoaded(initial.kind)
|
||||
}
|
||||
return () => clearTimeout(t)
|
||||
})
|
||||
|
||||
@@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
let scope = $state<Scope>(untrack(() => initialScope))
|
||||
let filter = $state('')
|
||||
|
||||
/**
|
||||
* Canonical entry point for changing the picker's scope. Triggers the
|
||||
* fetch for the kind(s) the new scope needs at the same point in time.
|
||||
* Replaces the older "react to `scope` change via `$effect`" wiring,
|
||||
* which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the
|
||||
* effect ended up subscribed to the signal it fills — every fetch
|
||||
* result re-fired it. With explicit callbacks the fetch is tied to
|
||||
* the user's action, never to a reactive consequence of that action.
|
||||
*/
|
||||
function setScope(next: Scope) {
|
||||
scope = next
|
||||
if (!next) return
|
||||
if (next.kind === 'all') for (const k of kinds) ensureLoaded(k)
|
||||
else ensureLoaded(next.kind)
|
||||
}
|
||||
|
||||
/** Tracks whether the last user action was mouse movement (true) or
|
||||
* keyboard nav (false). When false, row `mouseenter` events are ignored
|
||||
* — prevents the cursor from stealing the keyboard-driven highlight as
|
||||
@@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
* mounts under a stationary cursor doesn't clobber `initialHighlight`. */
|
||||
let mouseActive = $state(false)
|
||||
|
||||
// Seed from cache so kinds already fetched in this session render on the
|
||||
// first frame. Read once at mount: melt-ui mounts a fresh picker per
|
||||
// popover open, so workspace changes are picked up at the next open
|
||||
// without needing this seed to be reactive.
|
||||
// Seed from the last fetched snapshot so kinds already fetched in this
|
||||
// session render on the first frame. Each entry is replaced once
|
||||
// `loadKind` returns fresh data — stale-while-revalidate, so deploys and
|
||||
// AI-created drafts surface on the next open without explicit cache
|
||||
// busting.
|
||||
let loaded = $state<Partial<Record<Kind, Item[]>>>(
|
||||
(() => {
|
||||
if (!$workspaceStore) return {}
|
||||
@@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
async function ensureLoaded(kind: Kind) {
|
||||
if (!$workspaceStore) return
|
||||
if (loaded[kind]) return
|
||||
loadingKind[kind] = true
|
||||
// Always re-fetch. If we have nothing cached, show a spinner; if we do,
|
||||
// keep displaying it and quietly swap to fresh data when it lands.
|
||||
// `loaded[kind]` is read inside `untrack(...)` because this function is
|
||||
// reachable from the search `$effect` below — without the untrack,
|
||||
// that effect would subscribe to the signal `ensureLoaded` fills, and
|
||||
// each `loaded[kind] = items` (proxy `set` notifies even when the ref
|
||||
// is unchanged from cache) would refire it → runaway loop. Drill
|
||||
// navigation goes through `setScope` directly so it isn't affected.
|
||||
if (!untrack(() => loaded[kind])) loadingKind[kind] = true
|
||||
try {
|
||||
const items = await loadKind($workspaceStore, kind)
|
||||
loaded[kind] = items
|
||||
@@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the scope's kind on entry to a non-root level. The `'all'` scope
|
||||
// needs every kind loaded since it merges items across them.
|
||||
$effect(() => {
|
||||
if (!scope) return
|
||||
if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k)
|
||||
else ensureLoaded(scope.kind)
|
||||
})
|
||||
// Chat tools and session editor previews write drafts through
|
||||
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
|
||||
// the picker so users can navigate to in-flight items that haven't been
|
||||
// deployed yet. Filter to kinds the picker actually displays.
|
||||
//
|
||||
// Gated on the same dev flag as the rest of the sessions feature: without
|
||||
// it there are no sessions, so the only UserDrafts present are the
|
||||
// standalone editors' autosaves — surfacing those in the breadcrumb picker
|
||||
// would be surprising (they'd appear as navigable items that 404 on the
|
||||
// backend draft fetch). When the flag is off this is a no-op.
|
||||
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
|
||||
function aiDraftsForKind(k: Kind): Item[] {
|
||||
if (!isGlobalAiEnabled()) return []
|
||||
if (!$workspaceStore) return []
|
||||
const targetType = KIND_TO_DRAFT_TYPE[k]
|
||||
return listGlobalDrafts($workspaceStore)
|
||||
.filter((d) => d.type === targetType)
|
||||
.map((d) => ({
|
||||
path: d.path,
|
||||
summary: d.summary ?? '',
|
||||
kind: k,
|
||||
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
|
||||
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// Searching is global → load every kind.
|
||||
$effect(() => {
|
||||
@@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
leaves: Item[]
|
||||
}
|
||||
|
||||
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
|
||||
* scaffolded a script/flow/app via chat tools without the user saving
|
||||
* yet — those drafts should be navigable from the picker. Existing items
|
||||
* (same path) win to keep the backend's metadata (summary etc.). */
|
||||
function withAiDrafts(items: Item[], k: Kind): Item[] {
|
||||
const ai = aiDraftsForKind(k)
|
||||
if (ai.length === 0) return items
|
||||
const known = new Set(items.map((it) => it.path))
|
||||
return items.concat(ai.filter((d) => !known.has(d.path)))
|
||||
}
|
||||
|
||||
/** Inject the currently-edited item into a kind's list at its live path,
|
||||
* dropping the saved entry when a draft rename is in progress. Other kinds
|
||||
* pass through untouched. */
|
||||
@@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
* cached. */
|
||||
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
|
||||
if (!kinds.includes(k)) return []
|
||||
const items = withCurrent(list ?? [], k)
|
||||
const items = withAiDrafts(withCurrent(list ?? [], k), k)
|
||||
if (items.length === 0) return []
|
||||
return buildTreeFromItems(items)
|
||||
}
|
||||
@@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
* one folder hierarchy. Each leaf still carries its real kind, so the row
|
||||
* icon and `editPathFor` routing still work; folders contain a mix. */
|
||||
const allTree = $derived.by(() => {
|
||||
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
|
||||
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
|
||||
return merged.length === 0 ? [] : buildTreeFromItems(merged)
|
||||
})
|
||||
|
||||
@@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
let allItems = $derived<SearchInput[]>(
|
||||
kinds.flatMap((k) =>
|
||||
withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` }))
|
||||
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
|
||||
...it,
|
||||
_key: `${k}:${it.path}`
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
function drill(entry: Entry) {
|
||||
if (entry.type === 'kind') {
|
||||
scope = { kind: entry.kind }
|
||||
setScope({ kind: entry.kind })
|
||||
} else if (entry.type === 'dir') {
|
||||
scope = { kind: entry.kind, dir: entry.node.fullPath }
|
||||
setScope({ kind: entry.kind, dir: entry.node.fullPath })
|
||||
} else {
|
||||
pick(entry.item)
|
||||
}
|
||||
@@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
// just left, so the user sees where they came from.
|
||||
if (!scope.dir) {
|
||||
const leaving = kindKey(scope.kind)
|
||||
scope = undefined
|
||||
setScope(undefined)
|
||||
highlightedKey = leaving
|
||||
return
|
||||
}
|
||||
const leaving = dirKey(scope.kind, scope.dir)
|
||||
const parent = parentDirPath(scope.dir)
|
||||
scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }
|
||||
setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind })
|
||||
highlightedKey = leaving
|
||||
}
|
||||
|
||||
@@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
|
||||
|
||||
{#snippet leafRow(it: Item, secondary: string, baseClass: string)}
|
||||
{@const key = leafKey(it)}
|
||||
{@const isHl = key === highlightedKey}
|
||||
{@const isCur = isCurrent(it)}
|
||||
<button
|
||||
type="button"
|
||||
<WorkspaceItemRow
|
||||
kind={it.kind}
|
||||
summary={it.summary}
|
||||
{secondary}
|
||||
highlighted={key === highlightedKey}
|
||||
current={isCurrent(it)}
|
||||
id={idFor(key)}
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
|
||||
? 'bg-surface-hover'
|
||||
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''}"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
navKey={key}
|
||||
{baseClass}
|
||||
onclick={() => pick(it)}
|
||||
onmouseenter={() => setHoverHighlight(key)}
|
||||
>
|
||||
<RowIcon kind={it.kind} size={12} />
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if it.summary}
|
||||
<div class="text-xs text-primary truncate">{it.summary}</div>
|
||||
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
@component
|
||||
Visual row for a workspace item (script / flow / app / resource /
|
||||
schedule / trigger / …). Matches the leaf-row layout used by
|
||||
WorkspaceItemDrillPicker: RowIcon + summary line on top with mono path
|
||||
beneath, or just the mono path when there's no summary.
|
||||
|
||||
Pure presentation — the caller controls highlighting / current state via
|
||||
props, supplies the onclick/onmouseenter handlers, and can pass an
|
||||
`extras` snippet for right-side adornments (status dots, badges, …).
|
||||
The button uses `onmousedown={(e) => e.preventDefault()}` so the click
|
||||
doesn't steal focus from a sibling search input (matches the picker).
|
||||
-->
|
||||
<script module lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import RowIconType from '$lib/components/common/table/RowIcon.svelte'
|
||||
export type WorkspaceItemRowKind = ComponentProps<typeof RowIconType>['kind']
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
kind: WorkspaceItemRowKind
|
||||
/** For `kind: 'trigger'`, specifies the concrete trigger subtype.
|
||||
* Forwarded to RowIcon. */
|
||||
triggerKind?: string
|
||||
/** Optional summary text shown above the path. */
|
||||
summary?: string
|
||||
/** Mono path (or any secondary identifier). When summary is empty
|
||||
* this is the only visible text. */
|
||||
secondary: string
|
||||
/** Highlighted via keyboard nav. Used for `aria-selected` +
|
||||
* surface-hover background. */
|
||||
highlighted?: boolean
|
||||
/** "Currently editing this" — the picker uses this to grey out the
|
||||
* active row and disable its click. */
|
||||
current?: boolean
|
||||
/** DOM id, used for `aria-activedescendant`. */
|
||||
id?: string
|
||||
/** Stamped on the element as `data-nav-key` so the parent can
|
||||
* `pickerRoot.querySelector(...)` to scroll into view. */
|
||||
navKey?: string
|
||||
/** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */
|
||||
baseClass?: string
|
||||
/** Extra left padding (px) for tree-view indentation. Adds to the
|
||||
* default `px-3` horizontal padding. */
|
||||
indent?: number
|
||||
/** Title tooltip shown on hover; defaults to the secondary text. */
|
||||
title?: string
|
||||
/** When set, the row renders as an `<a href target="_blank">` link
|
||||
* instead of a `<button>`. Used by callers that want native
|
||||
* new-tab / cmd-click behaviour. `onclick` still forwards. */
|
||||
href?: string
|
||||
onclick?: () => void
|
||||
onmouseenter?: () => void
|
||||
/** Right-side adornments (status dot, badges, …). The `group` class
|
||||
* is always applied to the root so the snippet can use
|
||||
* `group-hover:*` utilities to reveal hover-only affordances. */
|
||||
extras?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
kind,
|
||||
triggerKind,
|
||||
summary,
|
||||
secondary,
|
||||
highlighted = false,
|
||||
current = false,
|
||||
id,
|
||||
navKey,
|
||||
baseClass = 'py-1.5',
|
||||
indent = 0,
|
||||
title,
|
||||
href,
|
||||
onclick,
|
||||
onmouseenter,
|
||||
extras
|
||||
}: Props = $props()
|
||||
|
||||
const rootClass = $derived(
|
||||
`group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}`
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{id}
|
||||
role="option"
|
||||
aria-selected={highlighted}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
data-nav-key={navKey}
|
||||
title={title ?? secondary}
|
||||
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
|
||||
class={rootClass}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
>
|
||||
<RowIcon {kind} {triggerKind} size={12} />
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if summary}
|
||||
<div class="text-xs text-primary truncate">{summary}</div>
|
||||
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extras}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{@render extras()}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
{id}
|
||||
role="option"
|
||||
aria-selected={highlighted}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
data-nav-key={navKey}
|
||||
title={title ?? secondary}
|
||||
style={indent ? `padding-left: calc(0.75rem + ${indent}px)` : undefined}
|
||||
class={rootClass}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
>
|
||||
<RowIcon {kind} {triggerKind} size={12} />
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if summary}
|
||||
<div class="text-xs text-primary truncate">{summary}</div>
|
||||
<div class="text-2xs text-secondary font-normal font-mono truncate">{secondary}</div>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate">{secondary}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extras}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{@render extras()}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -5,10 +5,8 @@
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import { initCss } from '../../utils'
|
||||
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
|
||||
import { Markdown, type Plugin } from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { rehypeGithubAlerts } from 'rehype-github-alerts'
|
||||
import { Markdown } from 'svelte-exmarkdown'
|
||||
import { markdownPlugins as plugins } from '$lib/components/markdownPlugins'
|
||||
import { classNames } from '$lib/utils'
|
||||
import { components } from '../../editor/component'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
@@ -31,11 +29,6 @@
|
||||
configuration
|
||||
}: Props = $props()
|
||||
|
||||
const plugins: Plugin[] = [
|
||||
gfmPlugin(),
|
||||
{ rehypePlugin: [rehypeRaw] },
|
||||
{ rehypePlugin: [rehypeGithubAlerts] }
|
||||
]
|
||||
const { app, worldStore, mode } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
const resolvedConfig = $state(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const bubble = createBubbler()
|
||||
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
|
||||
import { onMount, setContext, untrack } from 'svelte'
|
||||
import { getContext, onMount, setContext, untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
@@ -78,20 +78,29 @@
|
||||
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
onSavedNewAppPath,
|
||||
onNavigate,
|
||||
initialRevs
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
migrateApp(untrack(() => app))
|
||||
|
||||
// Inside a session pane the AIChatManager is injected via context. Sessions
|
||||
// have their own state machinery (sessionRuntime + per-fork backend), and
|
||||
// the user-facing $workspaceStore stays on the main workspace even when
|
||||
// the session is editing in a fork — so a UserDraft handle here would
|
||||
// share its LS key with the regular /apps/edit route and clobber both
|
||||
// sides' autosaves. Skip UserDraft entirely in that case.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
const appDraftPath = newApp ? '' : (path ?? '')
|
||||
const appDraftHandle = UserDraft.use<App>('app', appDraftPath)
|
||||
const appDraftHandle = inSessionPane ? undefined : UserDraft.use<App>('app', appDraftPath)
|
||||
// Prefer the persisted autosave over the prop when both exist (e.g.
|
||||
// /apps/add reload: the route always initializes `app` to an empty
|
||||
// template, but the user's last session is sitting in LS under the
|
||||
// empty-path entry). The route is responsible for wiping the entry
|
||||
// (`UserDraft.remove`) when it wants to force a fresh start —
|
||||
// `?nodraft=true`, template/hub loads, etc.
|
||||
const stateApp = $state(untrack(() => appDraftHandle.draft ?? app))
|
||||
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
|
||||
const appStore = writable<App>(stateApp)
|
||||
// Captured once on mount: the load-time revs are only used as the
|
||||
// seed meta on the very first persist of this entry. After that the
|
||||
@@ -111,6 +120,7 @@
|
||||
let firstMirror = true
|
||||
$effect(() => {
|
||||
readFieldsRecursively(stateApp)
|
||||
if (!appDraftHandle) return
|
||||
untrack(() => {
|
||||
// Resolve the meta to attach BEFORE the wipe — the wipe clears
|
||||
// in-memory meta and would otherwise force-seed `initialRevs`
|
||||
@@ -164,7 +174,7 @@
|
||||
groups: $userStore?.groups,
|
||||
username: $userStore?.username,
|
||||
name: $userStore?.name,
|
||||
query: urlParamsToObject(new URL(window.location.href).searchParams),
|
||||
query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }),
|
||||
hash: window.location.hash.substring(1),
|
||||
workspace: $workspaceStore,
|
||||
mode: 'editor',
|
||||
@@ -881,6 +891,7 @@
|
||||
rightPanelHidden={rightPanelSize === 0}
|
||||
bottomPanelHidden={runnablePanelSize === 0}
|
||||
{onSavedNewAppPath}
|
||||
{onNavigate}
|
||||
onShowLeftPanel={() => showLeftPanel()}
|
||||
onShowRightPanel={() => showRightPanel()}
|
||||
onShowBottomPanel={() => showBottomPanel()}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
import DebugPanel from './contextPanel/DebugPanel.svelte'
|
||||
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
@@ -101,6 +101,7 @@
|
||||
onHideRightPanel?: () => void
|
||||
onHideLeftPanel?: () => void
|
||||
onHideBottomPanel?: () => void
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -120,7 +121,8 @@
|
||||
onShowBottomPanel,
|
||||
onHideLeftPanel,
|
||||
onHideRightPanel,
|
||||
onHideBottomPanel
|
||||
onHideBottomPanel,
|
||||
onNavigate = undefined
|
||||
}: Props = $props()
|
||||
|
||||
/** Mirror of the path the user is editing in the pen popover. Initialized
|
||||
@@ -160,6 +162,14 @@
|
||||
const { history, jobsDrawerOpen, refreshComponents } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
// Sessions inject an AIChatManager via context; AppEditor skips its
|
||||
// UserDraft handle in that case, so the cleanup calls here must skip too
|
||||
// (otherwise we'd wipe a non-session tab's autosave at the same path). The
|
||||
// session-side equivalent is the View's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
|
||||
const loading = $state({
|
||||
publish: false,
|
||||
save: false
|
||||
@@ -213,7 +223,7 @@
|
||||
}
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('app', path)
|
||||
if (!inSessionPane) UserDraft.remove('app', path)
|
||||
onSavedNewAppPath?.(path)
|
||||
} catch (e) {
|
||||
sendUserToast('Error creating app', e)
|
||||
@@ -297,7 +307,6 @@
|
||||
preserve_on_behalf_of: preserveOnBehalfOf || undefined
|
||||
}
|
||||
})
|
||||
invalidatePicker($workspaceStore!, 'app')
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: $summary,
|
||||
@@ -314,7 +323,7 @@
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('app', $appPath)
|
||||
if (!inSessionPane) UserDraft.remove('app', $appPath)
|
||||
if ($appPath !== npath) {
|
||||
onSavedNewAppPath?.(npath)
|
||||
}
|
||||
@@ -791,7 +800,7 @@
|
||||
bind:path={newEditedPath}
|
||||
savedPath={$appPath || newPath || undefined}
|
||||
kind="app"
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
<div class="flex gap-2 {compactTopbar ? 'hidden' : ''}">
|
||||
{#if $app}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
newEditedPath = $bindable(),
|
||||
newPath,
|
||||
hideSecretUrl = false,
|
||||
preserveOnBehalfOf = $bindable(false)
|
||||
preserveOnBehalfOf = $bindable(false),
|
||||
rawApp = false
|
||||
}: {
|
||||
policy: any
|
||||
setPublishState: () => void
|
||||
@@ -51,6 +52,11 @@
|
||||
newPath: string
|
||||
hideSecretUrl?: boolean
|
||||
preserveOnBehalfOf?: boolean
|
||||
// Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic
|
||||
// (low-code) apps must NOT get the flag — it would force COEP on the
|
||||
// document and break no-CORP cross-origin subresources (external images,
|
||||
// {@html} embeds, CDN imports).
|
||||
rawApp?: boolean
|
||||
} = $props()
|
||||
|
||||
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
|
||||
@@ -91,6 +97,17 @@
|
||||
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
|
||||
}${customPath}`
|
||||
)
|
||||
|
||||
// When embedding a raw app in an iframe inside another Windmill app (or any
|
||||
// cross-origin-isolated page), the embedded document must set COEP. The
|
||||
// `wm_coep` flag opts the public app into the cross-origin isolation headers.
|
||||
// Only raw apps get it — for classic (low-code) apps COEP would break
|
||||
// no-CORP cross-origin subresources, so their snippet stays a plain iframe.
|
||||
let embedMode = $state(false)
|
||||
function toEmbedSnippet(url: string): string {
|
||||
const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url
|
||||
return `<iframe src="${finalUrl}" title="Windmill app" width="100%" height="600"></iframe>`
|
||||
}
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -122,7 +139,7 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
|
||||
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -247,18 +264,40 @@
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
disabled={appPath == ''}
|
||||
disabled={!savedApp}
|
||||
/>
|
||||
</div>
|
||||
{#if appPath == ''}
|
||||
{#if !savedApp}
|
||||
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
|
||||
{:else if secretUrlHref}
|
||||
<ClipboardPanel content={secretUrlHref} size="md" />
|
||||
<div class="flex justify-end mb-1">
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={embedMode}
|
||||
on:change={(e) => (embedMode = e.detail)}
|
||||
options={{ left: 'URL', right: 'Embed' }}
|
||||
/>
|
||||
</div>
|
||||
<ClipboardPanel
|
||||
content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref}
|
||||
size="md"
|
||||
/>
|
||||
{:else}<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
|
||||
embedding app must be the same as the one of Windmill)
|
||||
{#if embedMode}
|
||||
Paste this iframe snippet into another app.
|
||||
{#if rawApp}
|
||||
The <code>wm_coep</code> flag <Tooltip
|
||||
>Sets the cross-origin isolation headers (COEP) so the app can be embedded inside
|
||||
another Windmill app or any cross-origin-isolated page. Without it the browser blocks
|
||||
the iframe.</Tooltip
|
||||
> lets it load inside a cross-origin-isolated page.
|
||||
{/if}
|
||||
(if requiring login, top-level domain of embedding app must be the same as the one of Windmill)
|
||||
{:else}
|
||||
Share this url directly, or switch to <b>Embed</b> to get an iframe snippet.
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@@ -305,7 +344,10 @@
|
||||
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
|
||||
<div>Custom public URL</div>
|
||||
</div>
|
||||
<ClipboardPanel content={fullCustomUrl} size="md" />
|
||||
<ClipboardPanel
|
||||
content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
|
||||
>{dirtyCustomPath ? customPathError : ''}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
name: $userStore?.name,
|
||||
groups: $userStore?.groups,
|
||||
username: $userStore?.username,
|
||||
query: urlParamsToObject(page.url.searchParams),
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
workspace={effectiveWorkspace}
|
||||
|
||||
@@ -161,6 +161,8 @@ export interface AppEditorProps {
|
||||
replaceStateFn?: (path: string) => void
|
||||
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
|
||||
onSavedNewAppPath?: (path: string) => void
|
||||
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
/**
|
||||
* Backend revs at the load that produced `app`. Used as the seed
|
||||
* `UserDraft` meta on the first local autosave: until the handle has
|
||||
|
||||
@@ -78,6 +78,15 @@ this component just proposes new values.
|
||||
})
|
||||
}
|
||||
|
||||
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
|
||||
// stays active for a brief window after the menu closes — focusing our
|
||||
// input during that window causes checkFocusIn to slam focus back out, which
|
||||
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
|
||||
// for Melt's trap to release.
|
||||
export function edit() {
|
||||
setTimeout(startEditing, 50)
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
|
||||
// which unmounts the `<input>` and synchronously fires its `blur`
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
id?: string | undefined
|
||||
actions?: import('svelte').Snippet
|
||||
titleExtra?: import('svelte').Snippet
|
||||
/** Rendered fixed below the header, above the scrollable content. */
|
||||
banner?: import('svelte').Snippet
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@
|
||||
id,
|
||||
actions,
|
||||
titleExtra,
|
||||
banner,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -83,6 +86,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if banner}
|
||||
{@render banner()}
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
noPadding ? '' : 'p-4',
|
||||
|
||||
@@ -3,30 +3,61 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { type ScriptLang } from '$lib/gen'
|
||||
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
|
||||
import { aiChatManager, AIMode } from './AIChatManager.svelte'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import { base } from '$lib/base'
|
||||
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
|
||||
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
|
||||
let {
|
||||
hideHeader = false,
|
||||
hideModeSelector = false,
|
||||
forceDisabled = false,
|
||||
forceDisabledMessage = '',
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
inputPreface
|
||||
}: {
|
||||
hideHeader?: boolean
|
||||
hideModeSelector?: boolean
|
||||
// External "you can't type here" override. Used by sessions when
|
||||
// the session's committed workspace was deleted/archived so the
|
||||
// chat is effectively read-only until the user moves or discards
|
||||
// the session. Wins over the internal disabled derivation.
|
||||
forceDisabled?: boolean
|
||||
forceDisabledMessage?: string
|
||||
// Forwarded to AIChatDisplay. When true, the messages / input
|
||||
// columns are centered in a max-w-3xl px-8 box. Sessions opt
|
||||
// in; the narrow global-chat panel leaves it off.
|
||||
wideLayout?: boolean
|
||||
emptyHint?: import('svelte').Snippet
|
||||
inputPreface?: import('svelte').Snippet
|
||||
} = $props()
|
||||
|
||||
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
|
||||
const hasCopilot = $derived($copilotInfo.enabled)
|
||||
const disabled = $derived(
|
||||
!hasCopilot ||
|
||||
forceDisabled ||
|
||||
!hasCopilot ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
|
||||
)
|
||||
const disabledMessage = $derived(
|
||||
!hasCopilot
|
||||
? 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.`
|
||||
: ''
|
||||
forceDisabled
|
||||
? forceDisabledMessage
|
||||
: !hasCopilot
|
||||
? 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 = [
|
||||
@@ -53,6 +84,10 @@
|
||||
aiChatManager.sendRequest(options)
|
||||
}
|
||||
|
||||
export function focusInput() {
|
||||
aiChatDisplay?.focusInput()
|
||||
}
|
||||
|
||||
const historyManager = aiChatManager.historyManager
|
||||
|
||||
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
|
||||
@@ -129,4 +164,9 @@
|
||||
{disabled}
|
||||
{disabledMessage}
|
||||
{suggestions}
|
||||
{hideHeader}
|
||||
{hideModeSelector}
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
{inputPreface}
|
||||
></AIChatDisplay>
|
||||
|
||||
@@ -61,7 +61,12 @@ import { runChatLoop } from './chatLoop'
|
||||
import type { ReviewChangesOpts } from './monaco-adapter'
|
||||
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
|
||||
import type { WorkspaceMutationTarget } from './workspaceTools'
|
||||
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core'
|
||||
import {
|
||||
globalToolsFor,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
type GlobalToolHelpers
|
||||
} from './global/core'
|
||||
import { isGlobalAiEnabled } from './global/gate'
|
||||
|
||||
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
|
||||
@@ -208,13 +213,26 @@ export class AIChatManager {
|
||||
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
|
||||
// Set by AI sessions. Enables the session-only preview tools (open_preview /
|
||||
// get_preview_status) and their system-prompt guidance in GLOBAL mode; the
|
||||
// global side-panel chat leaves it false so those tools aren't offered.
|
||||
isSessionChat = false
|
||||
// The session this manager belongs to (session chats only). Carried into the
|
||||
// tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS
|
||||
// session rather than the UI-active one — keeps backgrounded sessions isolated.
|
||||
sessionId: string | undefined = undefined
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined,
|
||||
flow: this.flowAiChatHelpers !== undefined,
|
||||
app: this.appAiChatHelpers !== undefined,
|
||||
navigator: true,
|
||||
ask: true,
|
||||
API: true,
|
||||
script:
|
||||
this.flowAiChatHelpers === undefined &&
|
||||
this.scriptEditorOptions !== undefined &&
|
||||
!this.disabledModes.script,
|
||||
flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow,
|
||||
app: this.appAiChatHelpers !== undefined && !this.disabledModes.app,
|
||||
navigator: !this.disabledModes.navigator,
|
||||
ask: !this.disabledModes.ask,
|
||||
API: !this.disabledModes.API,
|
||||
// Dev-only gate. See `./global/gate.ts` for how to enable.
|
||||
global: isAIModeVisible(AIMode.GLOBAL)
|
||||
})
|
||||
@@ -495,9 +513,15 @@ export class AIChatManager {
|
||||
this.helpers = {}
|
||||
} else if (mode === AIMode.GLOBAL) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
this.systemMessage = prepareGlobalSystemMessage(customPrompt)
|
||||
this.tools = [...globalTools]
|
||||
this.helpers = {}
|
||||
this.systemMessage = prepareGlobalSystemMessage(customPrompt, {
|
||||
previewTools: this.isSessionChat
|
||||
})
|
||||
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
|
||||
this.helpers = {
|
||||
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
|
||||
testActiveFlow: async (args?: Record<string, any>) =>
|
||||
this.flowAiChatHelpers?.testFlow(args)
|
||||
} satisfies GlobalToolHelpers
|
||||
} else if (mode === AIMode.APP) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
this.systemMessage = prepareAppSystemMessage(customPrompt)
|
||||
@@ -795,6 +819,12 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Optional pre-flight hook called once per send, after validation but
|
||||
// before any UI state mutates or backend calls go out. Sessions use
|
||||
// this to commit/materialise the workspace (creating a staged fork via
|
||||
// the API) so the first message targets the correct workspace.
|
||||
beforeSend?: () => Promise<void> | void
|
||||
|
||||
sendRequest = async (
|
||||
options: {
|
||||
removeDiff?: boolean
|
||||
@@ -819,6 +849,24 @@ export class AIChatManager {
|
||||
if (!this.instructions.trim()) {
|
||||
return
|
||||
}
|
||||
if (this.beforeSend) {
|
||||
try {
|
||||
await this.beforeSend()
|
||||
} catch (e) {
|
||||
// beforeSend commits the session's workspace before the first
|
||||
// message hits the backend. If it throws, sending anyway would
|
||||
// silently target the wrong workspace (typically the parent), so
|
||||
// abort and tell the user — their message text stays in the input.
|
||||
console.error('AIChatManager beforeSend hook failed', e)
|
||||
sendUserToast(
|
||||
`Could not prepare the session before sending: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}. Your message was not sent — please try again.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
|
||||
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
|
||||
|
||||
@@ -16,7 +16,13 @@ vi.mock('$lib/gen', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('$lib/stores', () => ({
|
||||
workspaceStore: { subscribe: () => () => undefined }
|
||||
workspaceStore: { subscribe: () => () => undefined },
|
||||
userStore: {
|
||||
subscribe: (run: (value: { username: string }) => void) => {
|
||||
run({ username: 'admin' })
|
||||
return () => undefined
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('$lib/toast', () => ({
|
||||
@@ -55,10 +61,12 @@ vi.mock('esm-env', async (importOriginal) => ({
|
||||
|
||||
function createFlowHelpers({
|
||||
hasPendingChanges,
|
||||
acceptAllModuleActions
|
||||
acceptAllModuleActions,
|
||||
testFlow = vi.fn()
|
||||
}: {
|
||||
hasPendingChanges: () => boolean
|
||||
acceptAllModuleActions: () => void
|
||||
testFlow?: FlowAIChatHelpers['testFlow']
|
||||
}): FlowAIChatHelpers {
|
||||
return {
|
||||
getFlowAndSelectedId: vi.fn(),
|
||||
@@ -74,7 +82,7 @@ function createFlowHelpers({
|
||||
rejectAllModuleActions: vi.fn(),
|
||||
hasPendingChanges,
|
||||
selectStep: vi.fn(),
|
||||
testFlow: vi.fn(),
|
||||
testFlow,
|
||||
getLintErrors: vi.fn()
|
||||
} as unknown as FlowAIChatHelpers
|
||||
}
|
||||
@@ -162,6 +170,27 @@ describe('AIChatManager autonomy mode', () => {
|
||||
|
||||
expect(applied).toBe(true)
|
||||
})
|
||||
|
||||
it('does not pass the AI session id as a flow test conversation id in global mode', async () => {
|
||||
const manager = new AIChatManager()
|
||||
const testFlow = vi.fn(async () => 'job-flow-preview')
|
||||
|
||||
manager.isSessionChat = true
|
||||
manager.sessionId = 'htc1xouxd96dcyo6ruqo39'
|
||||
manager.setFlowHelpers(
|
||||
createFlowHelpers({
|
||||
hasPendingChanges: () => false,
|
||||
acceptAllModuleActions: vi.fn(),
|
||||
testFlow
|
||||
})
|
||||
)
|
||||
|
||||
manager.changeMode(AIMode.GLOBAL)
|
||||
const jobId = await manager.helpers.testActiveFlow({ name: 'Ada' })
|
||||
|
||||
expect(jobId).toBe('job-flow-preview')
|
||||
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager persisted autonomy default', () => {
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
onClick={() => onMenuOpen?.()}
|
||||
startIcon={{ icon: Menu }}
|
||||
iconOnly
|
||||
></Button>
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
{@render children?.()}
|
||||
@@ -96,5 +96,13 @@
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
<div
|
||||
class={classNames(
|
||||
'flex-1 min-h-0 flex flex-col',
|
||||
noBorder || $userStore?.operator || isMobile ? '' : isCollapsed ? 'pl-12' : 'pl-40',
|
||||
'transition-all ease-in-out duration-200'
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
import { CircleHelp } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import type { UserQuestionDisplay } from './shared'
|
||||
|
||||
// Sessions inject a per-pane `AIChatManager` via context; outside of
|
||||
// sessions getAiChatManager falls back to the global singleton. Without
|
||||
// this, answers clicked inside a session would dispatch to the singleton's
|
||||
// pending callbacks map (which doesn't have the session manager's question
|
||||
// callback), and the AI loop would stall.
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
toolCallId: string
|
||||
userQuestion: UserQuestionDisplay
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { aiChatManager, AIMode } from './AIChatManager.svelte'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode'
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
|
||||
@@ -69,4 +71,4 @@
|
||||
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ChatSchema extends IDBSchema {
|
||||
displayMessages: DisplayMessage[]
|
||||
title: string
|
||||
lastModified: number
|
||||
sessionId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +27,21 @@ export default class HistoryManager {
|
||||
title: string
|
||||
id: string
|
||||
lastModified: number
|
||||
sessionId?: string
|
||||
}
|
||||
> = $state({})
|
||||
|
||||
private currentChatId: string = $state(createLongHash())
|
||||
|
||||
// When set, this manager is bound to a session: only chats tagged with this id
|
||||
// are surfaced and new chats are saved with this id. When undefined (singleton),
|
||||
// session-tagged chats are excluded from history.
|
||||
private sessionId: string | undefined = $state(undefined)
|
||||
|
||||
private pastChats = $derived(
|
||||
Object.values(this.savedChats)
|
||||
.filter((c) => c.id !== this.currentChatId)
|
||||
.filter((c) => (this.sessionId ? c.sessionId === this.sessionId : !c.sessionId))
|
||||
.sort((a, b) => b.lastModified - a.lastModified)
|
||||
)
|
||||
|
||||
@@ -69,10 +77,33 @@ export default class HistoryManager {
|
||||
return this.currentChatId
|
||||
}
|
||||
|
||||
setCurrentChatId(id: string) {
|
||||
this.currentChatId = id
|
||||
}
|
||||
|
||||
setSessionId(id: string | undefined) {
|
||||
this.sessionId = id
|
||||
}
|
||||
|
||||
async tagChatWithSession(chatId: string, sessionId: string) {
|
||||
const existing = this.savedChats[chatId]
|
||||
if (!existing || existing.sessionId === sessionId) return
|
||||
const snapshot = $state.snapshot(existing)
|
||||
const updated = { ...snapshot, sessionId }
|
||||
this.savedChats = { ...this.savedChats, [chatId]: updated }
|
||||
if (this.indexDB) {
|
||||
await this.indexDB.put('chats', updated)
|
||||
}
|
||||
}
|
||||
|
||||
getPastChats() {
|
||||
return this.pastChats
|
||||
}
|
||||
|
||||
getAllSavedChats() {
|
||||
return Object.values(this.savedChats)
|
||||
}
|
||||
|
||||
async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
|
||||
if (displayMessages.length > 0) {
|
||||
// we don't want to save the snapshot in the history
|
||||
@@ -84,7 +115,8 @@ export default class HistoryManager {
|
||||
})),
|
||||
title: displayMessages[0].content.slice(0, 50),
|
||||
id: this.currentChatId,
|
||||
lastModified: Date.now()
|
||||
lastModified: Date.now(),
|
||||
...(this.sessionId ? { sessionId: this.sessionId } : {})
|
||||
}
|
||||
this.savedChats = {
|
||||
...this.savedChats,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import type { ToolDisplayMessage } from './shared'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
@@ -52,13 +54,12 @@
|
||||
{#if activeUserQuestion}
|
||||
<AskUserQuestionDisplay toolCallId={message.tool_call_id} userQuestion={activeUserQuestion} />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
|
||||
>
|
||||
<div class="bg-surface border border-border-light rounded-md overflow-hidden font-mono text-xs">
|
||||
<!-- Collapsible Header -->
|
||||
<button
|
||||
class={twMerge(
|
||||
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
|
||||
'w-full p-2 bg-surface-secondary/30 hover:bg-surface-hover transition-colors flex items-center justify-between text-left',
|
||||
isExpanded ? 'border-b border-border-light' : '',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
@@ -107,7 +108,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
|
||||
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
hasParameters ? 'border-t border-border-light' : ''
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import type { FlowAIChatHelpers } from './core'
|
||||
import { createInlineScriptSession } from './inlineScriptsUtils'
|
||||
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
import { getAiChatManager } from '../aiChatManagerContext'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { FlowCopilotContext } from '../../flow'
|
||||
import type { ScriptLintResult } from '../shared'
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ScriptService,
|
||||
type FlowModule,
|
||||
type InputTransform,
|
||||
type RawScript,
|
||||
JobService
|
||||
} from '$lib/gen'
|
||||
import { type FlowModule, type InputTransform, type RawScript } from '$lib/gen'
|
||||
import type {
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam
|
||||
@@ -30,7 +24,8 @@ import {
|
||||
formatScriptLintResult,
|
||||
type ScriptLintResult,
|
||||
createSearchWorkspaceTool,
|
||||
createGetRunnableDetailsTool
|
||||
createGetRunnableDetailsTool,
|
||||
executeFlowStepTestRun
|
||||
} from '../shared'
|
||||
import { createWorkspaceMutationTools } from '../workspaceTools'
|
||||
import type { ContextElement } from '../context'
|
||||
@@ -183,7 +178,7 @@ const setFlowJsonToolSchema = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe(
|
||||
'JSON string containing the optional array of semantic flow groups (summary, note, autocollapse, start_id, end_id, color). Pass null to clear groups.'
|
||||
'JSON string containing the optional array of semantic flow groups. Each group has summary, note, autocollapse, start_id, end_id, color. color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes or other strings. Pass null to clear groups.'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -442,99 +437,14 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
|
||||
const stepId = args.stepId
|
||||
const stepArgs = args.args || {}
|
||||
|
||||
// Find the step in the flow (includes preprocessor/failure modules)
|
||||
let targetModule: FlowModule | undefined = findModuleInFlow(flow.value, stepId) ?? undefined
|
||||
|
||||
if (!targetModule) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Step '${stepId}' not found in flow`,
|
||||
error: `Step with id '${stepId}' does not exist in the current flow`
|
||||
})
|
||||
throw new Error(
|
||||
`Step with id '${stepId}' not found in flow. Available steps: ${(flow.value.modules ?? []).map((m: FlowModule) => m.id).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const module = targetModule
|
||||
const moduleValue = module.value
|
||||
|
||||
if (moduleValue.type === 'rawscript') {
|
||||
// Test raw script step
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
content: moduleValue.content ?? '',
|
||||
language: moduleValue.language,
|
||||
args:
|
||||
module.id === SPECIAL_MODULE_IDS.PREPROCESSOR
|
||||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs }
|
||||
: stepArgs
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of step '${stepId}'...`,
|
||||
contextName: 'script'
|
||||
})
|
||||
} else if (moduleValue.type === 'script') {
|
||||
// Test script step - need to get the script content
|
||||
const script = moduleValue.hash
|
||||
? await ScriptService.getScriptByHash({
|
||||
workspace: workspace,
|
||||
hash: moduleValue.hash
|
||||
})
|
||||
: await ScriptService.getScriptByPath({
|
||||
workspace: workspace,
|
||||
path: moduleValue.path
|
||||
})
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
content: script.content,
|
||||
language: script.language,
|
||||
args:
|
||||
module.id === SPECIAL_MODULE_IDS.PREPROCESSOR
|
||||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...stepArgs }
|
||||
: stepArgs
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of script step '${stepId}'...`,
|
||||
contextName: 'script'
|
||||
})
|
||||
} else if (moduleValue.type === 'flow') {
|
||||
// Test flow step
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runFlowByPath({
|
||||
workspace: workspace,
|
||||
path: moduleValue.path,
|
||||
requestBody: stepArgs
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of flow step '${stepId}'...`,
|
||||
contextName: 'flow'
|
||||
})
|
||||
} else {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Step type '${moduleValue.type}' not supported for testing`,
|
||||
error: `Cannot test step of type '${moduleValue.type}'`
|
||||
})
|
||||
throw new Error(
|
||||
`Cannot test step of type '${moduleValue.type}'. Supported types: rawscript, script, flow`
|
||||
)
|
||||
}
|
||||
return executeFlowStepTestRun({
|
||||
flowValue: flow.value,
|
||||
stepId,
|
||||
args: stepArgs,
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId
|
||||
})
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Run flow step test',
|
||||
@@ -949,7 +859,7 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide
|
||||
- \`schema\`: Flow input schema in JSON Schema format (optional)
|
||||
- \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`)
|
||||
- \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`)
|
||||
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
|
||||
- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups.
|
||||
|
||||
**Example - Simple flow:**
|
||||
\`\`\`javascript
|
||||
|
||||
@@ -299,4 +299,23 @@ describe('validateFlowGroups', () => {
|
||||
const result = validateFlowGroups([{ start_id: 'a', end_id: 'c', summary: 'G' }], moduleIds)
|
||||
expect(result).toEqual([{ start_id: 'a', end_id: 'c', summary: 'G' }])
|
||||
})
|
||||
|
||||
it('rejects an unknown color name', () => {
|
||||
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: '#ff00aa' }])).toThrow(
|
||||
/color must be one of/
|
||||
)
|
||||
expect(() => validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'magenta' }])).toThrow(
|
||||
/color must be one of/
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a known color name', () => {
|
||||
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b', color: 'blue' }])
|
||||
expect(result).toEqual([{ start_id: 'a', end_id: 'b', color: 'blue' }])
|
||||
})
|
||||
|
||||
it('accepts a group with no color', () => {
|
||||
const result = validateFlowGroups([{ start_id: 'a', end_id: 'b' }])
|
||||
expect(result).toEqual([{ start_id: 'a', end_id: 'b' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { FlowModule, FlowValue, OpenFlow, RawScript } from '$lib/gen'
|
||||
import { forEachFlowModule } from '$lib/components/flows/dfs'
|
||||
import { findModuleInFlow } from '$lib/components/flows/flowTree'
|
||||
import { NoteColor } from '$lib/components/graph/noteColors'
|
||||
import type { InlineScriptSession } from './inlineScriptsUtils'
|
||||
|
||||
/** Allowed group color names — matches the NoteColor palette the group
|
||||
* editor uses. Other strings would render with default-blue styling at best
|
||||
* and break the color picker UI at worst. */
|
||||
const ALLOWED_GROUP_COLORS = new Set<string>(Object.values(NoteColor))
|
||||
|
||||
type FlowLike = Pick<OpenFlow, 'value'> & {
|
||||
schema?: Record<string, any>
|
||||
}
|
||||
@@ -71,6 +77,13 @@ export function validateFlowGroups(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (g.color !== undefined && g.color !== null) {
|
||||
if (typeof g.color !== 'string' || !ALLOWED_GROUP_COLORS.has(g.color)) {
|
||||
throw new Error(
|
||||
`Invalid group at index ${index}: color must be one of ${[...ALLOWED_GROUP_COLORS].join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return g as unknown as FlowGroup
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {},
|
||||
@@ -48,8 +48,21 @@ vi.mock('$lib/gen', async () => {
|
||||
getScriptByPath: vi.fn(async () => {
|
||||
throw new Error('getScriptByPath mock not configured')
|
||||
}),
|
||||
queryHubScripts: vi.fn(async () => []),
|
||||
getHubScriptContentByPath: vi.fn(async () => ''),
|
||||
listScripts: vi.fn(async () => [])
|
||||
}),
|
||||
JobService: wrapService(actual.JobService, {
|
||||
runScriptPreview: vi.fn(async () => 'job-script-preview'),
|
||||
runFlowPreview: vi.fn(async () => 'job-flow-preview'),
|
||||
runFlowByPath: vi.fn(async () => 'job-flow-by-path'),
|
||||
getJob: vi.fn(async () => ({
|
||||
type: 'CompletedJob',
|
||||
success: true,
|
||||
result: { ok: true },
|
||||
logs: 'test logs'
|
||||
}))
|
||||
}),
|
||||
FlowService: wrapService(actual.FlowService, {
|
||||
existsFlowByPath: vi.fn(async () => false),
|
||||
createFlow: vi.fn(async () => 'created'),
|
||||
@@ -105,7 +118,15 @@ vi.mock('./rawAppBundlerBridge', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
|
||||
import {
|
||||
globalTools,
|
||||
globalToolsFor,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
setDeployedInSessionHandler,
|
||||
setGetPreviewStatusHandler,
|
||||
setOpenPreviewHandler
|
||||
} from './core'
|
||||
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
|
||||
import { clearGlobalDrafts } from './userDraftAdapter'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
@@ -113,6 +134,7 @@ import {
|
||||
AppService,
|
||||
FlowService,
|
||||
HttpTriggerService,
|
||||
JobService,
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
ScriptService,
|
||||
@@ -138,12 +160,13 @@ function getGlobalTool(name: string): Tool<{}> {
|
||||
async function callGlobalTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
callbacks: ToolCallbacks = toolCallbacks
|
||||
callbacks: ToolCallbacks = toolCallbacks,
|
||||
helpers: Record<string, unknown> = {}
|
||||
): Promise<string> {
|
||||
return getGlobalTool(name).fn({
|
||||
args,
|
||||
workspace: WORKSPACE,
|
||||
helpers: {},
|
||||
helpers,
|
||||
toolCallbacks: callbacks,
|
||||
toolId: `test-${name}`
|
||||
})
|
||||
@@ -158,6 +181,17 @@ function localStorageSnapshot(): string {
|
||||
return values.join('\n')
|
||||
}
|
||||
|
||||
async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const promise = run()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
return await promise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
|
||||
describe('global AI tools', () => {
|
||||
beforeEach(() => {
|
||||
__resetUserDraftForTesting()
|
||||
@@ -166,6 +200,41 @@ describe('global AI tools', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('exposes hub search and path-aware test tools', () => {
|
||||
const names = globalTools.map((tool) => tool.def.function.name)
|
||||
|
||||
expect(names).toContain('search_hub_scripts')
|
||||
expect(names).toContain('test_run_script')
|
||||
expect(names).toContain('test_run_flow')
|
||||
expect(names).toContain('test_run_step')
|
||||
})
|
||||
|
||||
it('searches hub scripts without fetching script contents', async () => {
|
||||
vi.mocked(ScriptService.queryHubScripts).mockResolvedValueOnce([
|
||||
{
|
||||
version_id: 7,
|
||||
app: 'slack',
|
||||
summary: 'Send Message'
|
||||
}
|
||||
] as any)
|
||||
|
||||
const raw = await callGlobalTool('search_hub_scripts', {
|
||||
query: 'slack message'
|
||||
})
|
||||
|
||||
expect(ScriptService.queryHubScripts).toHaveBeenCalledWith({
|
||||
text: 'slack message',
|
||||
kind: 'script'
|
||||
})
|
||||
expect(ScriptService.getHubScriptContentByPath).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(raw)).toEqual([
|
||||
{
|
||||
path: 'hub/7/slack/send_message',
|
||||
summary: 'Send Message'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('redacts variable draft values when reading workspace items', async () => {
|
||||
await callGlobalTool('write_variable', {
|
||||
path: 'f/secrets/api_key',
|
||||
@@ -1075,6 +1144,44 @@ describe('global AI tools', () => {
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
|
||||
const onDeployed = vi.fn()
|
||||
setDeployedInSessionHandler(onDeployed)
|
||||
try {
|
||||
UserDraft.save(
|
||||
'raw_app',
|
||||
'f/apps/report',
|
||||
{
|
||||
summary: 'AI report',
|
||||
files: { '/index.tsx': 'console.log("app")' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
|
||||
await callGlobalTool(
|
||||
'deploy_workspace_item',
|
||||
{ type: 'app', path: 'f/apps/report' },
|
||||
toolCallbacks,
|
||||
{
|
||||
sessionId: 'sess-123'
|
||||
}
|
||||
)
|
||||
|
||||
// A raw app deploys under type 'app' but the preview addresses it as
|
||||
// 'raw_app'; the calling session id is threaded through so the deploy
|
||||
// reloads the issuing session's preview, not the UI-active one.
|
||||
expect(onDeployed).toHaveBeenCalledWith({
|
||||
sessionId: 'sess-123',
|
||||
kind: 'raw_app',
|
||||
path: 'f/apps/report'
|
||||
})
|
||||
} finally {
|
||||
setDeployedInSessionHandler(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('fills an empty rawscript module through set_flow_module_code', async () => {
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/empty-module',
|
||||
@@ -1100,7 +1207,7 @@ describe('global AI tools', () => {
|
||||
module_id: 'empty_step',
|
||||
code
|
||||
})
|
||||
).resolves.toContain('Updated local draft flow')
|
||||
).resolves.toContain('Updated flow')
|
||||
|
||||
await expect(
|
||||
callGlobalTool('read_flow_module_code', {
|
||||
@@ -1165,6 +1272,368 @@ describe('global AI tools', () => {
|
||||
expect(item.value.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('test_run_script previews local draft script content by path', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}'
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/draft-test',
|
||||
summary: 'Draft test script',
|
||||
language: 'bun',
|
||||
content
|
||||
})
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_script', {
|
||||
path: 'f/scripts/draft-test',
|
||||
args: { name: 'Ada' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/draft-test',
|
||||
content,
|
||||
args: { name: 'Ada' },
|
||||
language: 'bun'
|
||||
}
|
||||
})
|
||||
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
|
||||
expect(result).toContain('Result (SUCCESS)')
|
||||
expect(result).toContain('test logs')
|
||||
})
|
||||
|
||||
it('test_run_script previews deployed script content when no local draft exists', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/deployed-test',
|
||||
summary: 'Deployed test script',
|
||||
content: 'def main(name):\n return name',
|
||||
language: 'python3'
|
||||
} as any)
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_script', {
|
||||
path: 'f/scripts/deployed-test',
|
||||
args: { name: 'Grace' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(ScriptService.getScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/deployed-test'
|
||||
})
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/deployed-test',
|
||||
content: 'def main(name):\n return name',
|
||||
args: { name: 'Grace' },
|
||||
language: 'python3'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_flow previews local draft flow content by path', async () => {
|
||||
const modules = [{ id: 'start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/draft-test',
|
||||
summary: 'Draft test flow',
|
||||
modules: JSON.stringify(modules)
|
||||
})
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_flow', {
|
||||
path: 'f/flows/draft-test',
|
||||
args: { name: 'Ada' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/flows/draft-test',
|
||||
value: { modules },
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_flow previews deployed flow content when no local draft exists', async () => {
|
||||
const modules = [{ id: 'deployed_start', value: { type: 'identity' } }]
|
||||
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
|
||||
path: 'f/flows/deployed-test',
|
||||
summary: 'Deployed test flow',
|
||||
value: { modules },
|
||||
schema: {}
|
||||
} as any)
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_flow', {
|
||||
path: 'f/flows/deployed-test',
|
||||
args: { name: 'Grace' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(FlowService.getFlowByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/flows/deployed-test'
|
||||
})
|
||||
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/flows/deployed-test',
|
||||
value: { modules },
|
||||
args: { name: 'Grace' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => {
|
||||
UserDraft.save(
|
||||
'flow',
|
||||
'',
|
||||
{
|
||||
path: 'u/admin/live_flow',
|
||||
summary: 'Live flow',
|
||||
value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] },
|
||||
schema: {},
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/live_flow'
|
||||
})
|
||||
const testActiveFlow = vi.fn(async () => 'job-live-flow')
|
||||
|
||||
const result = await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_flow',
|
||||
{
|
||||
path: 'u/admin/live_flow',
|
||||
args: { name: 'Ada' }
|
||||
},
|
||||
toolCallbacks,
|
||||
{ testActiveFlow }
|
||||
)
|
||||
)
|
||||
|
||||
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
|
||||
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
|
||||
expect(result).toContain('Result (SUCCESS)')
|
||||
})
|
||||
|
||||
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
|
||||
UserDraft.save(
|
||||
'flow',
|
||||
'',
|
||||
{
|
||||
path: 'u/admin/live_flow_fallback',
|
||||
summary: 'Live flow fallback',
|
||||
value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] },
|
||||
schema: {},
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/live_flow_fallback'
|
||||
})
|
||||
const testActiveFlow = vi.fn(async () => undefined)
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_flow',
|
||||
{
|
||||
path: 'u/admin/live_flow_fallback',
|
||||
args: { name: 'Ada' }
|
||||
},
|
||||
toolCallbacks,
|
||||
{ testActiveFlow }
|
||||
)
|
||||
)
|
||||
|
||||
expect(testActiveFlow).toHaveBeenCalledWith({ name: 'Ada' })
|
||||
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'u/admin/live_flow_fallback',
|
||||
value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] },
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step previews rawscript steps from the local draft flow', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/rawscript-step',
|
||||
summary: 'Flow with rawscript',
|
||||
modules: JSON.stringify([
|
||||
{
|
||||
id: 'format_name',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
language: 'bun',
|
||||
content,
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_step', {
|
||||
path: 'f/flows/rawscript-step',
|
||||
stepId: 'format_name',
|
||||
args: { name: 'Ada' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
content,
|
||||
language: 'bun',
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step prefers local script drafts for script steps', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}'
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/step-script',
|
||||
summary: 'Step script',
|
||||
language: 'bun',
|
||||
content
|
||||
})
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/script-step',
|
||||
summary: 'Flow with script step',
|
||||
modules: JSON.stringify([
|
||||
{
|
||||
id: 'call_script',
|
||||
value: {
|
||||
type: 'script',
|
||||
path: 'f/scripts/step-script',
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_step', {
|
||||
path: 'f/flows/script-step',
|
||||
stepId: 'call_script',
|
||||
args: { name: 'Ada' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/step-script',
|
||||
content,
|
||||
language: 'bun',
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step previews local draft subflows for flow steps', async () => {
|
||||
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/nested-draft',
|
||||
summary: 'Nested draft flow',
|
||||
modules: JSON.stringify(nestedModules)
|
||||
})
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/parent-flow',
|
||||
summary: 'Parent flow',
|
||||
modules: JSON.stringify([
|
||||
{
|
||||
id: 'call_flow',
|
||||
value: {
|
||||
type: 'flow',
|
||||
path: 'f/flows/nested-draft',
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool('test_run_step', {
|
||||
path: 'f/flows/parent-flow',
|
||||
stepId: 'call_flow',
|
||||
args: { name: 'Ada' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(JobService.runFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/flows/nested-draft',
|
||||
value: { modules: nestedModules },
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step lists nested step ids when a step is not found', async () => {
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/nested-step-error',
|
||||
summary: 'Flow with nested step',
|
||||
modules: JSON.stringify([
|
||||
{
|
||||
id: 'loop_step',
|
||||
value: {
|
||||
type: 'forloopflow',
|
||||
iterator: { type: 'static', value: [1] },
|
||||
skip_failures: false,
|
||||
modules: [
|
||||
{
|
||||
id: 'nested_script_step',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
language: 'bun',
|
||||
content: 'export async function main() { return 1 }',
|
||||
input_transforms: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
await expect(
|
||||
callGlobalTool('test_run_step', {
|
||||
path: 'f/flows/nested-step-error',
|
||||
stepId: 'missing_nested_step',
|
||||
args: {}
|
||||
})
|
||||
).rejects.toThrow(/Available steps: loop_step, nested_script_step/)
|
||||
})
|
||||
|
||||
it('asks the user a question and returns the selected answer', async () => {
|
||||
const callbacks: ToolCallbacks = {
|
||||
setToolStatus: vi.fn(),
|
||||
@@ -1283,6 +1752,9 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
expect(content).toContain(
|
||||
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
|
||||
)
|
||||
expect(content).toContain(
|
||||
'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step'
|
||||
)
|
||||
expect(content).toContain('If the user message includes an ACTIVE EDITOR section')
|
||||
expect(content).not.toContain('AI draft')
|
||||
expect(content).not.toContain('UserDraft')
|
||||
@@ -1303,6 +1775,62 @@ describe('prepareGlobalSystemMessage', () => {
|
||||
expect(discard.requiresConfirmation).toBe(true)
|
||||
expect(deleteItem.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
describe('get_preview_status', () => {
|
||||
afterEach(() => {
|
||||
setGetPreviewStatusHandler(undefined)
|
||||
setOpenPreviewHandler(undefined)
|
||||
})
|
||||
|
||||
it('takes no arguments', () => {
|
||||
const tool = getGlobalTool('get_preview_status')
|
||||
expect(tool.def.function.parameters).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the session-only error when no handler is registered', async () => {
|
||||
setGetPreviewStatusHandler(undefined)
|
||||
const result = await callGlobalTool('get_preview_status', {})
|
||||
expect(result).toBe('Error: get_preview_status is only available inside an AI session.')
|
||||
})
|
||||
|
||||
it('dispatches to the registered session handler', async () => {
|
||||
setGetPreviewStatusHandler(() => 'The preview is currently open showing script "u/me/foo".')
|
||||
const result = await callGlobalTool('get_preview_status', {})
|
||||
expect(result).toBe('The preview is currently open showing script "u/me/foo".')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-only preview tools gating', () => {
|
||||
const toolNames = (sessionPreview: boolean) =>
|
||||
globalToolsFor({ sessionPreview }).map((t) => t.def.function.name)
|
||||
|
||||
it('excludes open_preview / get_preview_status outside a session', () => {
|
||||
const names = toolNames(false)
|
||||
expect(names).not.toContain('open_preview')
|
||||
expect(names).not.toContain('get_preview_status')
|
||||
// other tools are still present
|
||||
expect(names).toContain('write_script')
|
||||
})
|
||||
|
||||
it('includes open_preview / get_preview_status inside a session', () => {
|
||||
const names = toolNames(true)
|
||||
expect(names).toContain('open_preview')
|
||||
expect(names).toContain('get_preview_status')
|
||||
// session set is the full globalTools
|
||||
expect(names.length).toBe(globalTools.length)
|
||||
})
|
||||
|
||||
it('mentions open_preview in the system prompt only when preview tools are enabled', () => {
|
||||
const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string
|
||||
const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
|
||||
expect(off).not.toContain('open_preview')
|
||||
expect(on).toContain('open_preview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalUserMessage', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FlowService,
|
||||
GcpTriggerService,
|
||||
HttpTriggerService,
|
||||
JobService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
NatsTriggerService,
|
||||
@@ -55,6 +56,9 @@ import type {
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createToolDef,
|
||||
createSearchHubScriptsTool,
|
||||
executeFlowStepTestRun,
|
||||
executeTestRun,
|
||||
findAndReplace,
|
||||
type CreatedResourceTriggerKind,
|
||||
type Tool,
|
||||
@@ -84,6 +88,8 @@ import {
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
clearEphemeralSecretVariableDraftValue,
|
||||
@@ -385,6 +391,49 @@ const patchFlowJsonSchema = z.object({
|
||||
)
|
||||
})
|
||||
|
||||
const testRunArgsSchema = z
|
||||
.record(z.string(), z.any())
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Arguments to pass to the runnable. Omit or pass null when no arguments are needed.')
|
||||
|
||||
const testRunScriptSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the script to test.'),
|
||||
args: testRunArgsSchema
|
||||
})
|
||||
|
||||
const testRunScriptToolDef = createToolDef(
|
||||
testRunScriptSchema,
|
||||
'test_run_script',
|
||||
'Execute a preview-style test run of a script by path, preferring local draft content when it exists.',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
const testRunFlowSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the flow to test.'),
|
||||
args: testRunArgsSchema
|
||||
})
|
||||
|
||||
const testRunFlowToolDef = createToolDef(
|
||||
testRunFlowSchema,
|
||||
'test_run_flow',
|
||||
'Execute a preview-style test run of a flow by path, preferring local draft content when it exists.',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
const testRunStepSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the flow containing the step to test.'),
|
||||
stepId: z.string().describe('The id of the step/module to test.'),
|
||||
args: testRunArgsSchema
|
||||
})
|
||||
|
||||
const testRunStepToolDef = createToolDef(
|
||||
testRunStepSchema,
|
||||
'test_run_step',
|
||||
'Execute a test run of one step in a flow by path, preferring local draft flow/script content when it exists.',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
// ============= App tools (raw apps) =============
|
||||
|
||||
const backendRunnableSchema = z
|
||||
@@ -480,6 +529,17 @@ const deleteAppRunnableSchema = z.object({
|
||||
key: z.string().describe('Key of the backend runnable to remove.')
|
||||
})
|
||||
|
||||
const openPreviewSchema = z.object({
|
||||
kind: z
|
||||
.enum(['script', 'flow', 'raw_app'])
|
||||
.describe(
|
||||
'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
|
||||
),
|
||||
path: z.string().describe('Workspace path of the item to preview.')
|
||||
})
|
||||
|
||||
const getPreviewStatusSchema = z.object({})
|
||||
|
||||
const FRAMEWORK_KEYS = [
|
||||
'react19',
|
||||
'react18',
|
||||
@@ -514,10 +574,23 @@ const initAppSchema = z.object({
|
||||
.describe('Optional datatable configuration. Omit unless the user asked to wire one up.')
|
||||
})
|
||||
|
||||
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
|
||||
const buildGlobalSystemPrompt = (
|
||||
username: string,
|
||||
previewTools: boolean
|
||||
) => `You are Windmill's global workspace assistant.
|
||||
|
||||
The current user's workspace username is "${username}".
|
||||
|
||||
Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps.
|
||||
|
||||
Path conventions:
|
||||
- Every workspace path has exactly three segments and starts with one of two namespaces:
|
||||
- \`u/${username}/<name>\` — the current user's personal scope. Default for ad-hoc, exploratory, or scratch work.
|
||||
- \`f/<folder>/<name>\` — a shared folder scope. The folder must already exist; bare \`f/<name>\` is INVALID and will fail.
|
||||
- When the user gives a bare name without a namespace prefix (e.g. "create a flow called myflow"), default to \`u/${username}/<name>\`. Do NOT invent \`f/<name>\` — that is a structurally invalid path.
|
||||
- If the request implies shared / team work but doesn't name a specific folder (e.g. "the marketing flow"), ask which folder to use rather than guessing. Call \`list_workspace_items\` with \`type: ['folder']\` (or rely on the user's hint) before assuming a folder exists.
|
||||
- Only use an \`f/<folder>/<name>\` path when the user explicitly named the folder or you confirmed it exists.
|
||||
|
||||
Rules:
|
||||
- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items.
|
||||
- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.
|
||||
@@ -527,8 +600,14 @@ Rules:
|
||||
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
|
||||
- Use search_resource_types before write_resource.
|
||||
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
|
||||
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer local drafts, so testing does not require deployment.
|
||||
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit.
|
||||
- Keep context targeted.
|
||||
- Keep context targeted.${
|
||||
previewTools
|
||||
? `
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.`
|
||||
: ''
|
||||
}
|
||||
|
||||
Flows:
|
||||
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
|
||||
@@ -1194,7 +1273,7 @@ function getScriptInstructions(language: ScriptLang | undefined): string {
|
||||
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
|
||||
- A script draft is a workspace item: \`{ type: 'script', path, summary?, language, value, isDraft }\` where \`value\` is the source code string.
|
||||
- Use workspace paths such as \`f/folder/name\` or \`u/username/name\`. Preserve the current path/language when modifying unless the user asked to change them.
|
||||
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.
|
||||
- Use \`edit_script\` for small localized changes (provide \`old_string\`/\`new_string\`); use \`write_script\` for full rewrites.${note}
|
||||
|
||||
# Windmill script authoring reference (${selected})
|
||||
@@ -1206,6 +1285,7 @@ function getFlowInstructions(): string {
|
||||
return `# Global draft flow instructions
|
||||
|
||||
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
|
||||
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
|
||||
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions.
|
||||
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`.
|
||||
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
|
||||
@@ -1233,7 +1313,7 @@ function getAppInstructions(): string {
|
||||
return `# Global draft app instructions
|
||||
|
||||
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
|
||||
- App drafts are addressed by workspace path (e.g. \`f/folder/my_app\`). The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
|
||||
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
|
||||
- To create a new app, use \`init_app\` with a path, optional summary, and a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`). Confirm framework + path + summary with the user before calling — do not silently default to \`react19\` even though it is the recommended choice. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
|
||||
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
|
||||
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
|
||||
@@ -1296,6 +1376,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
return getInstructions(parsed.subject, parsed.language)
|
||||
}
|
||||
},
|
||||
createSearchHubScriptsTool(false),
|
||||
{
|
||||
def: createToolDef(
|
||||
askUserQuestionSchema,
|
||||
@@ -1520,6 +1601,39 @@ export const globalTools: Tool<{}>[] = [
|
||||
return patchFlowJson(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: testRunScriptToolDef,
|
||||
fn: async (ctx) => {
|
||||
const parsed = testRunScriptSchema.parse(ctx.args)
|
||||
return testRunScriptByPath(parsed, ctx)
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Run script test',
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
def: testRunFlowToolDef,
|
||||
fn: async (ctx) => {
|
||||
const parsed = testRunFlowSchema.parse(ctx.args)
|
||||
return testRunFlowByPath(parsed, ctx)
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Run flow test',
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
def: testRunStepToolDef,
|
||||
fn: async (ctx) => {
|
||||
const parsed = testRunStepSchema.parse(ctx.args)
|
||||
return testRunFlowStepByPath(parsed, ctx)
|
||||
},
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Run flow step test',
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
deployWorkspaceItemSchema,
|
||||
@@ -1533,7 +1647,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
confirmationMessage: 'Deploy local draft to workspace',
|
||||
fn: async (ctx) => {
|
||||
const parsed = deployWorkspaceItemSchema.parse(ctx.args)
|
||||
return deployDraft(parsed, ctx)
|
||||
return deployDraft(parsed, { ...ctx, sessionId: sessionIdFromCtx(ctx) })
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1739,13 +1853,137 @@ export const globalTools: Tool<{}>[] = [
|
||||
const parsed = deleteAppRunnableSchema.parse(ctx.args)
|
||||
return deleteAppRunnable(parsed, ctx)
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
openPreviewSchema,
|
||||
'open_preview',
|
||||
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
|
||||
),
|
||||
fn: async (ctx) => {
|
||||
const parsed = openPreviewSchema.parse(ctx.args)
|
||||
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
|
||||
}
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
getPreviewStatusSchema,
|
||||
'get_preview_status',
|
||||
'Check whether the side-panel preview is open in this AI session and which item (kind + path) it is showing. Call this before offering or calling open_preview so you do not re-open a preview that is already showing the item you just edited. Only meaningful inside a session.'
|
||||
),
|
||||
fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx))
|
||||
}
|
||||
]
|
||||
|
||||
// Tools that only make sense inside an AI session (they drive the session's
|
||||
// side-panel preview). The regular global side-panel chat shouldn't even be
|
||||
// offered them — see `globalToolsFor`.
|
||||
export const SESSION_PREVIEW_TOOL_NAMES = new Set(['open_preview', 'get_preview_status'])
|
||||
|
||||
/**
|
||||
* The global tool set for a given chat: the full `globalTools` for a session
|
||||
* chat, or `globalTools` minus the session-only preview tools for the regular
|
||||
* global side-panel chat.
|
||||
*/
|
||||
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
|
||||
return sessionPreview
|
||||
? globalTools
|
||||
: globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name))
|
||||
}
|
||||
|
||||
type WriteDraftCtx = {
|
||||
workspace: string
|
||||
toolId: string
|
||||
toolCallbacks: ToolCallbacks
|
||||
// Calling session id (session chats only), threaded through so a deploy
|
||||
// reloads the preview of the session that issued the deploy — not the
|
||||
// UI-active one. Undefined for the global side-panel chat.
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
// Sessions are the only context where `open_preview` makes sense — the global
|
||||
// singleton chat in the right side panel has nowhere to mount an editor pane.
|
||||
// The session runtime registers a handler at construction time so the tool
|
||||
// has somewhere to dispatch. When no session is active the handler is
|
||||
// undefined and the tool returns a polite error.
|
||||
// Per-manager tool helpers for a session chat. Each session's AIChatManager
|
||||
// sets `helpers = { sessionId }`, so a tool call carries the *calling* session's
|
||||
// id even when a different session is the UI-active one. Without this the
|
||||
// handlers below would route a backgrounded session's tool call to whatever
|
||||
// session the user happens to be viewing.
|
||||
export type SessionToolHelpers = { sessionId?: string }
|
||||
export type GlobalToolHelpers = SessionToolHelpers & {
|
||||
testActiveFlow?: (args?: Record<string, any>) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
|
||||
return (ctx.helpers as GlobalToolHelpers | undefined)?.sessionId
|
||||
}
|
||||
|
||||
function activeFlowTestFromCtx(
|
||||
ctx: { workspace: string; helpers?: unknown },
|
||||
path: string
|
||||
): GlobalToolHelpers['testActiveFlow'] | undefined {
|
||||
const activeEditor = getActiveGlobalEditorContext(ctx.workspace)
|
||||
if (activeEditor?.type !== 'flow' || activeEditor.path !== path) {
|
||||
return undefined
|
||||
}
|
||||
return (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow
|
||||
}
|
||||
|
||||
export type OpenPreviewHandler = (req: {
|
||||
sessionId: string | undefined
|
||||
kind: 'script' | 'flow' | 'raw_app'
|
||||
path: string
|
||||
}) => string
|
||||
|
||||
let openPreviewHandler: OpenPreviewHandler | undefined
|
||||
|
||||
export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): void {
|
||||
openPreviewHandler = handler
|
||||
}
|
||||
|
||||
function openSessionPreview(
|
||||
args: { kind: 'script' | 'flow' | 'raw_app'; path: string },
|
||||
sessionId: string | undefined
|
||||
) {
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
return openPreviewHandler({ ...args, sessionId })
|
||||
}
|
||||
|
||||
// Companion to `open_preview`: lets the assistant query the current preview
|
||||
// state (open? which item?) so it can avoid re-opening a preview already
|
||||
// showing the item it just edited. Registered by the session runtime
|
||||
// alongside the open-preview handler.
|
||||
export type GetPreviewStatusHandler = (sessionId: string | undefined) => string
|
||||
|
||||
let getPreviewStatusHandler: GetPreviewStatusHandler | undefined
|
||||
|
||||
export function setGetPreviewStatusHandler(handler: GetPreviewStatusHandler | undefined): void {
|
||||
getPreviewStatusHandler = handler
|
||||
}
|
||||
|
||||
function getSessionPreviewStatus(sessionId: string | undefined): string {
|
||||
if (!getPreviewStatusHandler) {
|
||||
return 'Error: get_preview_status is only available inside an AI session.'
|
||||
}
|
||||
return getPreviewStatusHandler(sessionId)
|
||||
}
|
||||
|
||||
// Registered by the session runtime to reload the open preview after a chat
|
||||
// deploy. Undefined outside a session.
|
||||
export type DeployedInSessionHandler = (req: {
|
||||
sessionId: string | undefined
|
||||
kind: 'script' | 'flow' | 'raw_app'
|
||||
path: string
|
||||
}) => void
|
||||
|
||||
let deployedInSessionHandler: DeployedInSessionHandler | undefined
|
||||
|
||||
export function setDeployedInSessionHandler(handler: DeployedInSessionHandler | undefined): void {
|
||||
deployedInSessionHandler = handler
|
||||
}
|
||||
|
||||
type DraftConfig = Record<string, any>
|
||||
@@ -1866,7 +2104,7 @@ function buildVariableDeployRequestBody(
|
||||
|
||||
function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void {
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
|
||||
content: `Writing draft ${type} "${path}"...`
|
||||
content: `Saving ${type} "${path}" to local storage…`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1891,13 +2129,13 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra
|
||||
: stored
|
||||
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
|
||||
content: `${verb} local draft ${stored.type} "${stored.path}"`,
|
||||
result: `Draft ${verb.toLowerCase()}`
|
||||
content: `${verb} ${stored.type} "${stored.path}" in local storage`,
|
||||
result: `Saved to local storage`
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `${verb} local draft ${stored.type} "${stored.path}". The workspace was not saved or deployed.`,
|
||||
message: `${verb} ${stored.type} "${stored.path}" in local storage (a browser-only local draft, not a workspace draft). It was not deployed.`,
|
||||
item: serializedItem
|
||||
},
|
||||
null,
|
||||
@@ -2321,6 +2559,145 @@ async function setFlowModuleCode(
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTestRunArgs(args: Record<string, any> | null | undefined): Record<string, any> {
|
||||
return args ?? {}
|
||||
}
|
||||
|
||||
function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue {
|
||||
return flowDraftAsEditableInput(flowDraft).value
|
||||
}
|
||||
|
||||
async function loadScriptForFlowStep(
|
||||
moduleValue: { path: string; hash?: string },
|
||||
workspace: string
|
||||
): Promise<{ content: string; language: ScriptLang }> {
|
||||
const draft = getGlobalDraft(workspace, 'script', moduleValue.path)
|
||||
if (draft) {
|
||||
if (typeof draft.value !== 'string' || !draft.language) {
|
||||
throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`)
|
||||
}
|
||||
return { content: draft.value, language: draft.language }
|
||||
}
|
||||
|
||||
const script = moduleValue.hash
|
||||
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
|
||||
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
|
||||
return { content: script.content, language: script.language }
|
||||
}
|
||||
|
||||
async function loadDraftFlowPreviewValue(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<FlowValue | undefined> {
|
||||
if (!getGlobalDraft(workspace, 'flow', path)) {
|
||||
return undefined
|
||||
}
|
||||
const nestedFlow = await loadFlowDraftValue(path, workspace)
|
||||
return flowDraftValueForPreview(nestedFlow.flow)
|
||||
}
|
||||
|
||||
async function testRunScriptByPath(
|
||||
args: z.infer<typeof testRunScriptSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const script = await loadScriptForEdit(args.path, workspace)
|
||||
const testArgs = normalizeTestRunArgs(args.args)
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
content: script.content,
|
||||
args: testArgs,
|
||||
language: script.language
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Running test for script "${args.path}"...`,
|
||||
contextName: 'script'
|
||||
})
|
||||
}
|
||||
|
||||
async function testRunFlowByPath(
|
||||
args: z.infer<typeof testRunFlowSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const testArgs = normalizeTestRunArgs(args.args)
|
||||
const testActiveFlow = activeFlowTestFromCtx(ctx, args.path)
|
||||
|
||||
if (testActiveFlow) {
|
||||
return executeTestRun({
|
||||
jobStarter: async () => {
|
||||
const jobId = await testActiveFlow(testArgs)
|
||||
if (jobId) {
|
||||
return jobId
|
||||
}
|
||||
|
||||
const flow = await loadFlowDraftValue(args.path, workspace)
|
||||
return JobService.runFlowPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
value: flowDraftValueForPreview(flow.flow),
|
||||
args: testArgs
|
||||
}
|
||||
})
|
||||
},
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting flow test run for "${args.path}"...`,
|
||||
contextName: 'flow'
|
||||
})
|
||||
}
|
||||
|
||||
const flow = await loadFlowDraftValue(args.path, workspace)
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runFlowPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
value: flowDraftValueForPreview(flow.flow),
|
||||
args: testArgs
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting flow test run for "${args.path}"...`,
|
||||
contextName: 'flow'
|
||||
})
|
||||
}
|
||||
|
||||
async function testRunFlowStepByPath(
|
||||
args: z.infer<typeof testRunStepSchema>,
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const flow = await loadFlowDraftValue(args.path, workspace)
|
||||
const flowValue = flowDraftValueForPreview(flow.flow)
|
||||
const testArgs = normalizeTestRunArgs(args.args)
|
||||
|
||||
return executeFlowStepTestRun({
|
||||
flowValue,
|
||||
stepId: args.stepId,
|
||||
args: testArgs,
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
loadScript: loadScriptForFlowStep,
|
||||
loadFlowPreviewValue: loadDraftFlowPreviewValue
|
||||
})
|
||||
}
|
||||
|
||||
async function initApp(
|
||||
args: {
|
||||
path: string
|
||||
@@ -2345,7 +2722,7 @@ async function initApp(
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Initializing app draft "${path}" with ${framework} template...`
|
||||
content: `Saving app "${path}" to local storage (${framework} template)…`
|
||||
})
|
||||
|
||||
const template = FRAMEWORK_TEMPLATES[framework]
|
||||
@@ -2365,13 +2742,13 @@ async function initApp(
|
||||
const stored = saveAppDraft(workspace, path, value)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Initialized app draft "${path}" (${framework})`,
|
||||
result: 'Draft initialized'
|
||||
content: `Saved app "${path}" to local storage (${framework})`,
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Initialized local draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`,
|
||||
message: `Initialized app "${path}" in local storage from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (a browser-only local draft, not a workspace draft). Use write_app_file / write_app_runnable to evolve it.`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2428,12 +2805,12 @@ async function writeAppFile(
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Updated ${target.filePath} in app "${args.path}"`,
|
||||
result: 'Draft updated'
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Updated local draft app "${args.path}" with frontend file "${target.filePath}".`,
|
||||
message: `Updated app "${args.path}" in local storage with frontend file "${target.filePath}".`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2468,12 +2845,12 @@ async function deleteAppFile(
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Removed ${target.filePath} from app "${args.path}"`,
|
||||
result: 'Draft updated'
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Removed "${target.filePath}" from local draft app "${args.path}".`,
|
||||
message: `Removed "${target.filePath}" from app "${args.path}" in local storage.`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2551,12 +2928,12 @@ async function patchAppFile(
|
||||
const stored = saveAppDraft(workspace, path, value, meta)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Patched ${target.filePath} in app "${path}"`,
|
||||
result: 'Draft updated'
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Patched "${target.filePath}" in local draft app "${path}".`,
|
||||
message: `Patched "${target.filePath}" in app "${path}" in local storage.`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2594,12 +2971,12 @@ async function writeAppRunnable(
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Updated runnable "${key}" in app "${path}"`,
|
||||
result: 'Draft updated'
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Updated local draft app "${path}" with runnable "${key}".`,
|
||||
message: `Updated app "${path}" in local storage with runnable "${key}".`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2628,12 +3005,12 @@ async function deleteAppRunnable(
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Removed runnable "${key}" from app "${path}"`,
|
||||
result: 'Draft updated'
|
||||
result: 'Saved to local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Removed runnable "${key}" from local draft app "${path}".`,
|
||||
message: `Removed runnable "${key}" from app "${path}" in local storage.`,
|
||||
item: stored
|
||||
},
|
||||
null,
|
||||
@@ -2719,13 +3096,13 @@ async function discardLocalDraft(
|
||||
deleteGlobalDraft(workspace, type, path, triggerKind)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Discarded local draft ${type} "${path}"`,
|
||||
result: 'Draft discarded'
|
||||
content: `Discarded ${type} "${path}" from local storage`,
|
||||
result: 'Discarded from local storage'
|
||||
})
|
||||
return JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: `Discarded local draft ${type} "${path}". The deployed workspace item was not changed.`,
|
||||
message: `Discarded the local-storage draft for ${type} "${path}". The deployed workspace item was not changed.`,
|
||||
type,
|
||||
path,
|
||||
triggerKind
|
||||
@@ -2744,7 +3121,7 @@ async function deployDraft(
|
||||
},
|
||||
ctx: WriteDraftCtx
|
||||
): Promise<string> {
|
||||
const { workspace, toolId, toolCallbacks } = ctx
|
||||
const { workspace, toolId, toolCallbacks, sessionId } = ctx
|
||||
const { type, path, trigger_kind: triggerKind, deployment_message: deploymentMessage } = args
|
||||
|
||||
if (type === 'trigger' && !triggerKind) {
|
||||
@@ -2933,6 +3310,17 @@ async function deployDraft(
|
||||
|
||||
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
|
||||
|
||||
// Reload the session preview if it's open on the deployed item. Map the
|
||||
// deploy type to the preview kind — a raw app deploys under 'app' but the
|
||||
// preview addresses it as 'raw_app'; non-previewable types map to undefined.
|
||||
const previewKindByType: Partial<Record<WorkspaceItemType, 'script' | 'flow' | 'raw_app'>> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
app: 'raw_app'
|
||||
}
|
||||
const kind = previewKindByType[type]
|
||||
if (kind) deployedInSessionHandler?.({ sessionId, kind, path })
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deployed ${type} "${path}"`,
|
||||
result: 'Deployed',
|
||||
@@ -3010,9 +3398,11 @@ async function deleteWorkspaceItem(
|
||||
}
|
||||
|
||||
export function prepareGlobalSystemMessage(
|
||||
customPrompt?: string
|
||||
customPrompt?: string,
|
||||
opts?: { previewTools?: boolean }
|
||||
): ChatCompletionSystemMessageParam {
|
||||
let content = GLOBAL_SYSTEM_PROMPT
|
||||
const username = get(userStore)?.username ?? ''
|
||||
let content = buildGlobalSystemPrompt(username, opts?.previewTools ?? false)
|
||||
if (customPrompt?.trim()) {
|
||||
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { get } from 'svelte/store'
|
||||
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { findModuleInModules } from '$lib/components/flows/flowTree'
|
||||
import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree'
|
||||
import type { FunctionParameters } from 'openai/resources/shared.mjs'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
FlowService,
|
||||
JobService,
|
||||
type CompletedJob,
|
||||
type FlowValue,
|
||||
type FlowModule,
|
||||
type ScriptLang,
|
||||
type Script,
|
||||
type Flow
|
||||
} from '$lib/gen'
|
||||
@@ -1082,6 +1084,164 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
type FlowStepScriptLoader = (
|
||||
moduleValue: { path: string; hash?: string },
|
||||
workspace: string
|
||||
) => Promise<{ content: string; language: ScriptLang }>
|
||||
|
||||
type FlowStepPreviewLoader = (path: string, workspace: string) => Promise<FlowValue | undefined>
|
||||
|
||||
export type FlowStepTestRunConfig = {
|
||||
flowValue: FlowValue
|
||||
stepId: string
|
||||
args?: Record<string, any> | null
|
||||
workspace: string
|
||||
toolCallbacks: ToolCallbacks
|
||||
toolId: string
|
||||
loadScript?: FlowStepScriptLoader
|
||||
loadFlowPreviewValue?: FlowStepPreviewLoader
|
||||
}
|
||||
|
||||
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
|
||||
return args ?? {}
|
||||
}
|
||||
|
||||
function flowStepArgsForModule(moduleId: string, args: Record<string, any>): Record<string, any> {
|
||||
return moduleId === SPECIAL_MODULE_IDS.PREPROCESSOR
|
||||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args }
|
||||
: args
|
||||
}
|
||||
|
||||
function getAvailableFlowStepIds(flowValue: FlowValue): string {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...extractAllModules(flowValue.modules ?? []).map((module: FlowModule) => module.id),
|
||||
...(flowValue.preprocessor_module ? [flowValue.preprocessor_module.id] : []),
|
||||
...(flowValue.failure_module ? [flowValue.failure_module.id] : [])
|
||||
])
|
||||
).join(', ')
|
||||
}
|
||||
|
||||
async function loadDeployedScriptForFlowStep(
|
||||
moduleValue: { path: string; hash?: string },
|
||||
workspace: string
|
||||
): Promise<{ content: string; language: ScriptLang }> {
|
||||
const script = moduleValue.hash
|
||||
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
|
||||
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
|
||||
return { content: script.content, language: script.language }
|
||||
}
|
||||
|
||||
export async function executeFlowStepTestRun({
|
||||
flowValue,
|
||||
stepId,
|
||||
args,
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
loadScript = loadDeployedScriptForFlowStep,
|
||||
loadFlowPreviewValue
|
||||
}: FlowStepTestRunConfig): Promise<string> {
|
||||
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
|
||||
|
||||
if (!targetModule) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Step "${stepId}" not found in flow`,
|
||||
error: `Step with id "${stepId}" does not exist in the current flow`
|
||||
})
|
||||
throw new Error(
|
||||
`Step with id "${stepId}" not found in flow. Available steps: ${getAvailableFlowStepIds(flowValue)}`
|
||||
)
|
||||
}
|
||||
|
||||
const moduleValue = targetModule.value
|
||||
const stepArgs = normalizeFlowStepArgs(args)
|
||||
|
||||
if (moduleValue.type === 'rawscript') {
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
content: moduleValue.content ?? '',
|
||||
language: moduleValue.language,
|
||||
args: flowStepArgsForModule(targetModule.id, stepArgs)
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of step "${stepId}"...`,
|
||||
contextName: 'script'
|
||||
})
|
||||
}
|
||||
|
||||
if (moduleValue.type === 'script') {
|
||||
const script = await loadScript(moduleValue, workspace)
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: moduleValue.path,
|
||||
content: script.content,
|
||||
language: script.language,
|
||||
args: flowStepArgsForModule(targetModule.id, stepArgs)
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of script step "${stepId}"...`,
|
||||
contextName: 'script'
|
||||
})
|
||||
}
|
||||
|
||||
if (moduleValue.type === 'flow') {
|
||||
const previewValue = await loadFlowPreviewValue?.(moduleValue.path, workspace)
|
||||
if (previewValue) {
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runFlowPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: moduleValue.path,
|
||||
value: previewValue,
|
||||
args: stepArgs
|
||||
}
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of draft flow step "${stepId}"...`,
|
||||
contextName: 'flow'
|
||||
})
|
||||
}
|
||||
|
||||
return executeTestRun({
|
||||
jobStarter: () =>
|
||||
JobService.runFlowByPath({
|
||||
workspace,
|
||||
path: moduleValue.path,
|
||||
requestBody: stepArgs
|
||||
}),
|
||||
workspace,
|
||||
toolCallbacks,
|
||||
toolId,
|
||||
startMessage: `Starting test run of flow step "${stepId}"...`,
|
||||
contextName: 'flow'
|
||||
})
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Step type "${moduleValue.type}" not supported for testing`,
|
||||
error: `Cannot test step of type "${moduleValue.type}"`
|
||||
})
|
||||
throw new Error(
|
||||
`Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow`
|
||||
)
|
||||
}
|
||||
|
||||
function formatLogs(logs: string | undefined): undefined | string {
|
||||
if (logs && logs.trim()) {
|
||||
if (logs.length <= MAX_LOG_LENGTH) {
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
import type { Flow, Job } from '$lib/gen'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import FlowAIChat from '../copilot/chat/flow/FlowAIChat.svelte'
|
||||
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
|
||||
import {
|
||||
AIChatManager,
|
||||
aiChatManager as singletonAiChatManager,
|
||||
AIMode
|
||||
} from '../copilot/chat/AIChatManager.svelte'
|
||||
import type { GraphModuleState } from '../graph'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import type { ModulesTestStates } from '../modulesTest.svelte'
|
||||
@@ -22,6 +26,8 @@
|
||||
import { extractAllModules } from '../copilot/chat/shared'
|
||||
import type { Snippet } from 'svelte'
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
|
||||
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
|
||||
|
||||
interface Props {
|
||||
loading: boolean
|
||||
@@ -132,14 +138,18 @@
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.FLOW)
|
||||
if (!sessionScopedManager) {
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.FLOW)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
aiChatManager.flowOptions = undefined
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
||||
if (!sessionScopedManager) {
|
||||
aiChatManager.saveAndClear()
|
||||
aiChatManager.changeMode(AIMode.NAVIGATOR)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -867,6 +867,7 @@
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
syncExternalCode
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
@@ -930,6 +931,7 @@
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
syncExternalCode
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{#if $workerTags}
|
||||
{#if $workerTags?.length > 0}
|
||||
<div class="w-40">
|
||||
{#if flowStore.val.tag == undefined || isPreprocessor}
|
||||
{#if flowStore.val.tag == undefined || isPreprocessor || flowStore.val.value?.preserve_step_tags}
|
||||
<WorkerTagSelect
|
||||
{noLabel}
|
||||
{placeholder}
|
||||
|
||||
@@ -63,7 +63,14 @@
|
||||
let displayWorkerTagPicker = $state(false)
|
||||
|
||||
run(() => {
|
||||
flowStore.val.tag ? (displayWorkerTagPicker = true) : null
|
||||
if (flowStore.val.tag) {
|
||||
displayWorkerTagPicker = true
|
||||
} else if (flowStore.val.value.preserve_step_tags) {
|
||||
// preserve_step_tags has no effect without a flow worker tag; clear it so it
|
||||
// doesn't linger as invisible state when the tag is removed via any path
|
||||
// (the picker, the toggle, or the YAML editor).
|
||||
flowStore.val.value.preserve_step_tags = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let activeAdvancedOptions = $derived([
|
||||
@@ -78,6 +85,7 @@
|
||||
{ name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) },
|
||||
{ name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) },
|
||||
{ name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) },
|
||||
{ name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) },
|
||||
{ name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) },
|
||||
{ name: 'Early Stop', active: Boolean(flowStore.val.value.skip_expr) },
|
||||
{ name: 'Early Return', active: Boolean(flowStore.val.value.early_return) },
|
||||
@@ -182,6 +190,8 @@
|
||||
on:change={() => {
|
||||
displayWorkerTagPicker = !displayWorkerTagPicker
|
||||
if (!displayWorkerTagPicker) {
|
||||
// Clearing the tag triggers the reactive block above, which also
|
||||
// resets preserve_step_tags so it doesn't linger as invisible state.
|
||||
flowStore.val.tag = undefined
|
||||
}
|
||||
}}
|
||||
@@ -197,6 +207,25 @@
|
||||
<div transition:slide={{ duration: 120 }} class="mt-2">
|
||||
<WorkerTagPicker bind:tag={flowStore.val.tag} popupPlacement="top-end" />
|
||||
</div>
|
||||
{#if flowStore.val.tag}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="mt-2 pl-4 border-l border-surface-selected"
|
||||
>
|
||||
<Toggle
|
||||
textClass="font-medium"
|
||||
size="xs"
|
||||
bind:checked={flowStore.val.value.preserve_step_tags}
|
||||
options={{
|
||||
right: 'Preserve step worker tags',
|
||||
rightTooltip:
|
||||
'By default the flow worker tag above is propagated to and overrides every step, script and nested sub-flow. Enable this to instead let steps that declare their own worker tag run on it. Steps without their own tag still inherit the flow tag.',
|
||||
rightDocumentationLink:
|
||||
'https://www.windmill.dev/docs/core_concepts/worker_groups'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
markRemovedAsShadowed?: boolean
|
||||
controlsPosition?: 'top' | 'bottom'
|
||||
outerDivClass?: string
|
||||
/** Fires when the computed graph height changes. Diff views can use
|
||||
* this to equalize heights of side-by-side graphs. */
|
||||
onHeight?: (height: number) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -273,7 +276,8 @@
|
||||
onMoveMultiple = undefined,
|
||||
movingIds = undefined,
|
||||
controlsPosition = 'top',
|
||||
outerDivClass = ''
|
||||
outerDivClass = '',
|
||||
onHeight = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// Initialize note manager with fine-grained reactivity
|
||||
@@ -759,6 +763,7 @@
|
||||
const computed = maxBottom - minY
|
||||
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
|
||||
}
|
||||
onHeight?.(height)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Plugin } from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import { rehypeGithubAlerts } from 'rehype-github-alerts'
|
||||
|
||||
/**
|
||||
* Shared plugin chain for rendering user-supplied Markdown (script/flow/resource
|
||||
* descriptions, flow-graph notes, the App "Markdown" component, ...).
|
||||
*
|
||||
* Order matters and is security-sensitive:
|
||||
* 1. `gfmPlugin` — GitHub-flavored Markdown.
|
||||
* 2. `rehypeRaw` — re-parses embedded raw HTML into live hast nodes.
|
||||
* 3. `rehypeSanitize` — strips dangerous nodes (`<script>`, `<iframe>`,
|
||||
* `<svg><script>`, `on*` handlers, `javascript:` URLs)
|
||||
* from the raw-parsed tree. Without this stage the raw
|
||||
* HTML reaches the DOM and executes (stored XSS).
|
||||
* 4. `rehypeGithubAlerts` — runs AFTER sanitize so the trusted alert markup it
|
||||
* injects (including inline SVG icons) is preserved
|
||||
* without having to allowlist SVG for user input.
|
||||
*
|
||||
* Any Markdown sink that renders user input MUST use this chain rather than
|
||||
* assembling its own `rehypeRaw` pipeline.
|
||||
*/
|
||||
export const markdownPlugins: Plugin[] = [
|
||||
gfmPlugin(),
|
||||
{ rehypePlugin: [rehypeRaw] },
|
||||
{ rehypePlugin: [rehypeSanitize] },
|
||||
{ rehypePlugin: [rehypeGithubAlerts] }
|
||||
]
|
||||
@@ -66,6 +66,9 @@
|
||||
}
|
||||
| undefined
|
||||
diffDrawer?: DiffDrawer | undefined
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
/** Fired after a successful deploy; the session preview reloads on it. */
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
/** Initial collapsed state for the file/runnable sidebar. The user's
|
||||
* toggled preference is persisted under `sidebarStorageKey`; this prop
|
||||
* only seeds the very first open. */
|
||||
@@ -75,6 +78,14 @@
|
||||
* preference. */
|
||||
sidebarStorageKey?: string
|
||||
liveEditorDraftStoragePath?: string
|
||||
/** Initial value for the "Split with Preview" tab-bar toggle. Defaults
|
||||
* to `true` (split mode, preview always pinned to the right). Set
|
||||
* `false` when the editor mounts inside a context that wants single-
|
||||
* view by default with the Preview tab selected — e.g. session
|
||||
* previews, where the editor pane is already narrow. The user can
|
||||
* still toggle the mode after mount; this prop only seeds the
|
||||
* initial state. */
|
||||
defaultSplitWithPreview?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -88,9 +99,12 @@
|
||||
newPath = undefined,
|
||||
savedApp = $bindable(undefined),
|
||||
diffDrawer = undefined,
|
||||
onNavigate,
|
||||
onDeploy = undefined,
|
||||
defaultSidebarCollapsed = false,
|
||||
sidebarStorageKey = 'raw-app-sidebar-collapsed',
|
||||
liveEditorDraftStoragePath = undefined
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
defaultSplitWithPreview = true
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
@@ -225,7 +239,9 @@
|
||||
}
|
||||
let tabs: TabItem[] = $state([previewTab])
|
||||
let activeTabId: string = $state(PREVIEW_TAB_ID)
|
||||
let splitWithPreview: boolean = $state(true)
|
||||
// Seed from the prop, then own the state locally so the user's toggle
|
||||
// after mount sticks even if the prop reference changes.
|
||||
let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview))
|
||||
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
|
||||
activeTabId === PREVIEW_TAB_ID
|
||||
? 'preview'
|
||||
@@ -255,11 +271,23 @@
|
||||
const showRunnable = $derived(activeTabKind === 'runnable')
|
||||
// Mount the UI Builder iframe the first time a file is shown (paneA has
|
||||
// width then; mounting it at 0-width breaks the VS Code workbench), and
|
||||
// keep it mounted so tab switches don't reload it.
|
||||
// keep it mounted so tab switches don't reload it. Mount it as soon as
|
||||
// either pane needs it: `showSource` for the source-editor view, OR the
|
||||
// preview tab is active — the Preview iframe is fed by `preview`
|
||||
// postMessages bundled by the UI Builder iframe, so it needs to be
|
||||
// mounted even when the user opens the editor straight on Preview (e.g.
|
||||
// session previews seeded with `defaultSplitWithPreview=false`).
|
||||
let iframeShouldMount = $state(false)
|
||||
$effect(() => {
|
||||
if (showSource) iframeShouldMount = true
|
||||
if (showSource || activeTabKind === 'preview') iframeShouldMount = true
|
||||
})
|
||||
// Width of the editor area (both inner panes). The UI Builder iframe is
|
||||
// pre-mounted while it's the inactive tab so the editor is ready instantly;
|
||||
// but the VS Code workbench inside crashes if it boots at 0 size. So while
|
||||
// inactive we keep the iframe at this real width and hide it with
|
||||
// `visibility` instead of collapsing it — Monaco boots correctly and
|
||||
// revealing a file is just an unhide (no reload, no relayout, no latency).
|
||||
let editorAreaWidth = $state(0)
|
||||
|
||||
// Inner pane sizes are a pure function of mode + active tab → derived.
|
||||
// `paneARatio` is the user's last manual split drag (set by rememberPaneDrag).
|
||||
@@ -994,7 +1022,11 @@
|
||||
ensureFileTab(selectedDocument)
|
||||
// Don't auto-activate — the user's tab choice wins.
|
||||
// But if no file tab is currently active, fall in line.
|
||||
if (activeTabKind === 'preview' && tabs.length === 2) {
|
||||
// Skip this auto-activation in single-view-with-preview
|
||||
// mode (the caller seeded `defaultSplitWithPreview=false`
|
||||
// because Preview is the intended starting tab); the
|
||||
// iframe's first setActiveDocument shouldn't fight that.
|
||||
if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) {
|
||||
activateTab(id)
|
||||
}
|
||||
}
|
||||
@@ -1158,9 +1190,14 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Open a default file on mount (boots the iframe; avoids a blank preview).
|
||||
// Layout isn't persisted — each open starts fresh in split mode.
|
||||
// Open a default file on mount (boots the iframe in split mode and gives
|
||||
// the user something to edit on the left). When the caller seeded
|
||||
// `defaultSplitWithPreview=false` we instead want the Preview tab as the
|
||||
// only-visible / active surface, so skip the file-tab activation — the
|
||||
// iframe still boots via `populateFiles`/`setFilesInIframe` even without
|
||||
// a selected document.
|
||||
onMount(() => {
|
||||
if (!splitWithPreview) return
|
||||
if (tabs.length === 1) {
|
||||
const def = pickDefaultFile(files)
|
||||
if (def) activateTab(ensureFileTab(def))
|
||||
@@ -1332,6 +1369,8 @@
|
||||
{data}
|
||||
{runnables}
|
||||
{getBundle}
|
||||
{onNavigate}
|
||||
{onDeploy}
|
||||
canUndo={historyManager.canUndo}
|
||||
canRedo={historyManager.canRedo}
|
||||
onUndo={handleUndo}
|
||||
@@ -1415,6 +1454,7 @@
|
||||
Preview previously hid every tab.
|
||||
-->
|
||||
<div
|
||||
bind:clientWidth={editorAreaWidth}
|
||||
class="h-full w-full min-h-0 {splitWithPreview && activeTabKind !== 'preview'
|
||||
? 'tabs-content-split'
|
||||
: 'tabs-content-single'}"
|
||||
@@ -1448,7 +1488,21 @@
|
||||
{/snippet}
|
||||
</DraggableTabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="absolute inset-0" style="display: {showSource ? 'block' : 'none'}">
|
||||
<!--
|
||||
Keep the UI Builder iframe mounted at a real (non-zero) size even
|
||||
when it isn't the active tab: a hidden 0×0 mount crashes the VS
|
||||
Code workbench's layout ("Unable to figure out browser width and
|
||||
height") and wedges it on "Loading editor" with no recovery. While
|
||||
inactive we size it to the editor area's width and hide it with
|
||||
`visibility` (not `display`), so Monaco boots correctly and
|
||||
revealing a file is an instant unhide.
|
||||
-->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style={showSource
|
||||
? ''
|
||||
: `right: auto; width: ${editorAreaWidth || 800}px; visibility: hidden; pointer-events: none;`}
|
||||
>
|
||||
{#if iframeShouldMount}
|
||||
<iframe
|
||||
bind:this={iframe}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { isMac, userPathPrefix } from '$lib/utils'
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
|
||||
import { AppService, type Policy } from '$lib/gen'
|
||||
@@ -58,11 +58,20 @@
|
||||
// session-pane PR lands. Untyped getContext to avoid coupling to the
|
||||
// AIChatManager class export (which lives on the chat-visuals PR).
|
||||
const inSessionPane = !!getContext('aiChatManager')
|
||||
// In a session pane the editor does NOT own the localStorage draft — the
|
||||
// session runtime does, keyed by the session's (fork) workspace. So the
|
||||
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
|
||||
// own removal (it would target the wrong, main-`$workspaceStore` key). The
|
||||
// session-side equivalent is RawAppEditorView's `onDeploy` →
|
||||
// `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads
|
||||
// the preview to the deployed version.
|
||||
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
|
||||
import type { RawAppData } from './dataTableRefUtils'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import UnsavedConfirmationModal from '../common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
|
||||
|
||||
// async function hash(message) {
|
||||
// try {
|
||||
@@ -115,7 +124,10 @@
|
||||
onOpenYamlEditor?: () => void
|
||||
sidebarCollapsed?: boolean
|
||||
onToggleSidebar?: () => void
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
liveEditorDraftStoragePath?: string
|
||||
// Fired after a successful deploy; lets the session preview reload.
|
||||
onDeploy?: (e: { path: string }) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -140,13 +152,15 @@
|
||||
onOpenYamlEditor = undefined,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar = undefined,
|
||||
liveEditorDraftStoragePath = undefined
|
||||
onNavigate = undefined,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onDeploy = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let newEditedPath = $state(
|
||||
untrack(() =>
|
||||
newApp
|
||||
? userPathPrefix($userStore?.username) + random_adj() + '_app'
|
||||
? newPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
|
||||
: newPath || appPath || ''
|
||||
)
|
||||
)
|
||||
@@ -269,8 +283,9 @@
|
||||
}
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('raw_app', path)
|
||||
if (!inSessionPane) UserDraft.remove('raw_app', path)
|
||||
dispatch('savedNewAppPath', path)
|
||||
onDeploy?.({ path })
|
||||
} catch (e) {
|
||||
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
@@ -364,7 +379,6 @@
|
||||
css
|
||||
}
|
||||
})
|
||||
invalidatePicker($workspaceStore!, 'app')
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
@@ -381,10 +395,11 @@
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
UserDraft.remove('raw_app', appPath)
|
||||
if (!inSessionPane) UserDraft.remove('raw_app', appPath)
|
||||
if (appPath !== npath) {
|
||||
dispatch('savedNewAppPath', npath)
|
||||
}
|
||||
onDeploy?.({ path: npath })
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
@@ -513,6 +528,18 @@
|
||||
|
||||
let jobsDrawerOpen = $state(false)
|
||||
|
||||
function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
return {
|
||||
savedValue: savedApp,
|
||||
modifiedValue: {
|
||||
summary: summary,
|
||||
value: app,
|
||||
path: newEditedPath || savedApp?.path,
|
||||
policy,
|
||||
custom_path: customPath
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
|
||||
|
||||
$effect(() => {
|
||||
@@ -520,6 +547,14 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Inside a session pane the editor's content is continuously persisted to the
|
||||
UserDraft (localStorage), so tearing the editor down on navigation loses
|
||||
nothing — skip the unsaved-changes prompt. The standalone /apps_raw editor
|
||||
keeps it. -->
|
||||
{#if !inSessionPane}
|
||||
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
|
||||
{/if}
|
||||
|
||||
<DeployOverrideConfirmationModal
|
||||
{deployedBy}
|
||||
{confirmCallback}
|
||||
@@ -564,7 +599,7 @@
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (appPath == '') {
|
||||
if (newApp || appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
@@ -585,7 +620,7 @@
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != '' || customPathError != '' || app == undefined}
|
||||
on:click={() => {
|
||||
if (appPath == '') {
|
||||
if (newApp || appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newEditedPath)
|
||||
@@ -604,6 +639,7 @@
|
||||
{appPath}
|
||||
{onLatest}
|
||||
{savedApp}
|
||||
rawApp
|
||||
bind:summary
|
||||
bind:customPath
|
||||
bind:deploymentMsg
|
||||
@@ -691,7 +727,7 @@
|
||||
savedPath={appPath || newPath || undefined}
|
||||
kind="app"
|
||||
raw_app
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
||||
/>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
@@ -36,9 +36,20 @@ export interface ScriptBuilderProps {
|
||||
savedPrimarySchedule?: ScheduleTrigger | undefined
|
||||
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
|
||||
children?: import('svelte').Snippet
|
||||
onDeploy?: (e: { path: string; hash: string }) => void
|
||||
// Fires on every successful deploy. `stay` is true for "Deploy & Stay here"
|
||||
// and for lib scripts (the editor stays in place rather than navigating to
|
||||
// the deployed item) — consumers should skip post-deploy navigation when set.
|
||||
onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void
|
||||
onDeployError?: (e: { path: string; error: any }) => void
|
||||
onHistoryRestore?: () => void
|
||||
onSeeDetails?: (e: { path: string }) => void
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
// Forwarded to the underlying ScriptEditor. When true, the right-hand
|
||||
// test/run pane opens collapsed. Used by the session preview.
|
||||
initialTestPanelCollapsed?: boolean
|
||||
// Treat the path as already chosen (seeds the path "dirty" flag) so the
|
||||
// summary→path auto-slug for new scripts (initialPath == '') doesn't
|
||||
// overwrite it. Used by the session preview, which opens AI-created scripts
|
||||
// as new but with a path the AI already assigned.
|
||||
initialPathChosen?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { untrack } from 'svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import type { Flow } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { flowDraftSig } from './flowDraftSig'
|
||||
import { initFlowState } from '$lib/components/flows/flowState'
|
||||
import SessionItemNotFound from './SessionItemNotFound.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
isActiveSession = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
path: string
|
||||
workspaceId: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
/**
|
||||
* Only the visible session should claim the workspace's live-editor
|
||||
* slot — without this, a hidden warm-mounted session can overwrite the
|
||||
* active session's UserDraft live-editor target (one slot per
|
||||
* (workspace, kind)), so chat actions like discard / "the open editor"
|
||||
* resolve to the wrong session.
|
||||
*/
|
||||
isActiveSession?: boolean
|
||||
} = $props()
|
||||
|
||||
let selectedId = $state('settings-metadata')
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (workspaceId && path) {
|
||||
untrack(() => runtime.loadFlow(workspaceId, path))
|
||||
}
|
||||
})
|
||||
|
||||
// In a session pane, "restore" just reloads from the current state — the
|
||||
// session target stays put. The Diff drawer's primary use here is viewing
|
||||
// the diff; restore is best-effort.
|
||||
async function restoreFromCurrentTarget() {
|
||||
diffDrawer?.closeDrawer()
|
||||
await runtime.loadFlow(workspaceId, path)
|
||||
}
|
||||
|
||||
// Mark this editor as the "live editor" for the session's workspace so
|
||||
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
|
||||
// this path. Same registration the regular /flows/edit page does on
|
||||
// mount, scoped to the session's (forked) workspace.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
// claim the workspace's single live-editor slot, else chat actions on the
|
||||
// visible session resolve to the hidden one's path.
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (!isActiveSession) return
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: workspaceId,
|
||||
itemKind: 'flow',
|
||||
storagePath: path,
|
||||
effectivePath: runtime.flowStore.val?.path ?? path
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('flow', { workspace: workspaceId, storagePath: path })
|
||||
})
|
||||
|
||||
// Bidirectional sync between this preview and `UserDraft<Flow>`.
|
||||
// We hold a *live* handle (useMany) rather than reading via the static
|
||||
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
|
||||
// `$state` cell for (workspace, 'flow', path), and that cell is what lets
|
||||
// the chat's writes (UserDraft.save, from write_flow / patch_flow_json /
|
||||
// set_flow_module_code) reach this preview. Without a live entry those
|
||||
// writes only touch localStorage and the inbound effect below never
|
||||
// re-fires. A reactive getter is used (not `use()`) because switching
|
||||
// open_preview to another flow swaps `path` without remounting this view,
|
||||
// so the handle must re-acquire.
|
||||
//
|
||||
// One-way-reactive discipline: inbound tracks only the handle's draft,
|
||||
// outbound tracks only `flowStore.val`; the read on the "other side"
|
||||
// inside each effect goes through `untrack()`. Without that asymmetry, a
|
||||
// user keystroke would re-fire the inbound effect with the pre-keystroke
|
||||
// stored value and revert the edit.
|
||||
const draftHandles = UserDraft.useMany<Flow>(() => [
|
||||
{ itemKind: 'flow', path, workspace: workspaceId }
|
||||
])
|
||||
let lastInboundSig: string | undefined = $state(undefined)
|
||||
|
||||
// Store → editor. Re-runs when the handle's draft changes (AI write from
|
||||
// this session's chat or another session). flowStore reads are untracked
|
||||
// so the editor's own mutations don't refire this effect.
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
const incoming = draftHandles[0]?.draft
|
||||
if (!incoming) return
|
||||
const sig = flowDraftSig(incoming)
|
||||
untrack(() => {
|
||||
if (runtime.loadedPath !== path) return
|
||||
if (sig === lastInboundSig) return
|
||||
const current = runtime.flowStore.val
|
||||
if (!current) return
|
||||
lastInboundSig = sig
|
||||
runtime.flowStore.val = {
|
||||
...current,
|
||||
value: incoming.value,
|
||||
schema: incoming.schema ?? current.schema,
|
||||
summary: incoming.summary ?? current.summary
|
||||
}
|
||||
// flowStateStore is keyed by module_id; after an AI write the set
|
||||
// of module ids may differ, so rebuild the UI state. This wipes
|
||||
// per-module test args / preview output for the new flow — a
|
||||
// known v1 trade-off, see the plan's caveats.
|
||||
void initFlowState(runtime.flowStore.val, runtime.flowStateStore)
|
||||
})
|
||||
})
|
||||
|
||||
// Editor → store. Re-runs on any deep mutation of flowStore.val
|
||||
// (modules, schema, module bodies). The store read is untracked.
|
||||
// Debounced 150ms so a typing burst inside an inline rawscript editor
|
||||
// results in one serialise-and-write instead of one per keystroke.
|
||||
let outboundTimer: ReturnType<typeof setTimeout> | undefined
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (runtime.loadedPath !== path) return
|
||||
const flow = runtime.flowStore.val
|
||||
if (!flow) return
|
||||
const sig = flowDraftSig(flow)
|
||||
if (sig === lastInboundSig) return
|
||||
if (outboundTimer) clearTimeout(outboundTimer)
|
||||
outboundTimer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
const current = UserDraft.get<Flow>('flow', path, { workspace: workspaceId })
|
||||
if (current && flowDraftSig(current) === sig) return
|
||||
UserDraft.save('flow', path, flow, { workspace: workspaceId })
|
||||
})
|
||||
}, 150)
|
||||
return () => {
|
||||
if (outboundTimer) clearTimeout(outboundTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if runtime.savedFlow.val}
|
||||
<DiffDrawer
|
||||
bind:this={diffDrawer}
|
||||
restoreDeployed={restoreFromCurrentTarget}
|
||||
restoreDraft={restoreFromCurrentTarget}
|
||||
isFlow
|
||||
/>
|
||||
{/if}
|
||||
{#if runtime.loadingFlow && !runtime.loadedPath}
|
||||
<div class="p-4 text-secondary text-sm">Loading flow {path}…</div>
|
||||
{:else if runtime.notFound && !runtime.loadedPath}
|
||||
<SessionItemNotFound kind="flow" {path} {onNavigate} />
|
||||
{:else}
|
||||
<!-- customUi hides the in-editor "Flow AI Chat" button: the session already
|
||||
has its own AI chat in the left pane, so the toggle is redundant here. -->
|
||||
<FlowBuilder
|
||||
flowStore={runtime.flowStore}
|
||||
flowStateStore={runtime.flowStateStore}
|
||||
initialPath={path}
|
||||
newFlow={!runtime.savedFlow.val}
|
||||
{selectedId}
|
||||
loading={runtime.loadingFlow && !runtime.loadedPath}
|
||||
bind:savedFlow={runtime.savedFlow.val}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
customUi={{ topBar: { aiBuilder: false } }}
|
||||
onSaveDraft={() => runtime.scheduleForkComparisonRefresh()}
|
||||
onDeploy={() => {
|
||||
// FlowBuilder has no deploy toast and the session stays put, so toast
|
||||
// here, then sync the preview to deployed (pulls the new locks + version_id).
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,732 @@
|
||||
<script lang="ts">
|
||||
import { parentFolderKey } from './forkDiffNav'
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
Loader2,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
User
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
|
||||
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
|
||||
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
|
||||
import { getItemValue } from '$lib/utils_workspace_deploy'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { editUrlFor as buildEditUrl } from './forkEditUrl'
|
||||
|
||||
let {
|
||||
forkWorkspaceId,
|
||||
parentWorkspaceId
|
||||
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
let loading = $state(false)
|
||||
let error: string | undefined = $state(undefined)
|
||||
let searchQuery = $state('')
|
||||
let diffStyle = $state<'sbs' | 'inline'>('sbs')
|
||||
const inlineDiff = $derived(diffStyle === 'inline')
|
||||
|
||||
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
|
||||
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
|
||||
export function open() {
|
||||
drawer?.openDrawer()
|
||||
void fetchComparison()
|
||||
// Pull focus into the filter input so keyboard nav works without an
|
||||
// extra click — drawer transition needs a tick first.
|
||||
setTimeout(() => searchInputEl?.focus(), 50)
|
||||
}
|
||||
|
||||
function openReview() {
|
||||
goto(`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`)
|
||||
}
|
||||
|
||||
async function fetchComparison() {
|
||||
loading = true
|
||||
error = undefined
|
||||
// Per-item raw diffs are cached for the lifetime of the drawer.
|
||||
// `loadDiffFor` early-returns on cache hit, so without this reset an
|
||||
// edit-then-reopen would show fresh summary/counts but stale expanded
|
||||
// raw content for any item the user had already drilled into.
|
||||
loadedDiffs = {}
|
||||
summaries = {}
|
||||
try {
|
||||
comparison = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parentWorkspaceId,
|
||||
targetWorkspaceId: forkWorkspaceId
|
||||
})
|
||||
// Diffs are expanded by default, so eagerly populate each row's
|
||||
// content. Each loadDiffFor is idempotent and per-item, so
|
||||
// rendering proceeds as values arrive.
|
||||
if (comparison) {
|
||||
for (const d of comparison.diffs) {
|
||||
void loadDiffFor(d)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fork diff: comparison failed', e)
|
||||
error = `Failed to load comparison: ${e}`
|
||||
comparison = undefined
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
|
||||
|
||||
function statusOf(d: WorkspaceItemDiff): DiffStatus {
|
||||
if (d.exists_in_fork && !d.exists_in_source) return 'added'
|
||||
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
|
||||
if (d.ahead > 0 && d.behind > 0) return 'conflict'
|
||||
return 'modified'
|
||||
}
|
||||
|
||||
function itemKey(d: WorkspaceItemDiff): string {
|
||||
return `${d.kind}/${d.path}`
|
||||
}
|
||||
|
||||
// Editor URL for a diff row, scoped to the fork workspace.
|
||||
function editUrlFor(d: WorkspaceItemDiff): string | undefined {
|
||||
return buildEditUrl(d, forkWorkspaceId)
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
script: 'Script',
|
||||
flow: 'Flow',
|
||||
app: 'App',
|
||||
raw_app: 'Raw app',
|
||||
resource: 'Resource',
|
||||
variable: 'Variable',
|
||||
resource_type: 'Resource type',
|
||||
folder: 'Folder',
|
||||
schedule: 'Schedule',
|
||||
http_trigger: 'HTTP route',
|
||||
websocket_trigger: 'Websocket trigger',
|
||||
kafka_trigger: 'Kafka trigger',
|
||||
nats_trigger: 'NATS trigger',
|
||||
postgres_trigger: 'Postgres trigger',
|
||||
mqtt_trigger: 'MQTT trigger',
|
||||
sqs_trigger: 'SQS trigger',
|
||||
gcp_trigger: 'GCP trigger',
|
||||
azure_trigger: 'Azure trigger',
|
||||
email_trigger: 'Email trigger'
|
||||
}
|
||||
|
||||
// Lazily-loaded raw values per item, keyed by itemKey. Shaping (content
|
||||
// vs metadata, YAML, lang detection) is owned by WorkspaceItemDiffViewer.
|
||||
type LoadedDiff = {
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error?: string
|
||||
parentRaw?: unknown
|
||||
forkRaw?: unknown
|
||||
}
|
||||
let loadedDiffs: Record<string, LoadedDiff> = $state({})
|
||||
// Per-item summary, derived from the fetched raw value so the tree on
|
||||
// the left can show summary above the mono path (matches the picker).
|
||||
let summaries: Record<string, string | undefined> = $state({})
|
||||
|
||||
async function loadDiffFor(d: WorkspaceItemDiff) {
|
||||
const key = itemKey(d)
|
||||
if (loadedDiffs[key]) return
|
||||
loadedDiffs[key] = { state: 'loading' }
|
||||
|
||||
try {
|
||||
// Source (parent) — empty for items only in fork. Fork — empty for
|
||||
// items only in source. We swallow per-side errors so an "added"
|
||||
// item still renders cleanly against an empty original.
|
||||
const [parentRaw, forkRaw] = await Promise.all([
|
||||
d.exists_in_source
|
||||
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
|
||||
: Promise.resolve(undefined),
|
||||
d.exists_in_fork
|
||||
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
|
||||
: Promise.resolve(undefined)
|
||||
])
|
||||
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
|
||||
// Prefer the fork's summary (the "current" side); fall back to parent.
|
||||
const summary =
|
||||
(forkRaw && typeof forkRaw === 'object' && (forkRaw as any).summary) ||
|
||||
(parentRaw && typeof parentRaw === 'object' && (parentRaw as any).summary) ||
|
||||
undefined
|
||||
if (typeof summary === 'string' && summary.trim().length > 0) {
|
||||
summaries[key] = summary
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fork diff: loadDiff failed', d, e)
|
||||
loadedDiffs[key] = {
|
||||
state: 'error',
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDetailsToggle(d: WorkspaceItemDiff, e: Event) {
|
||||
const target = e.currentTarget as HTMLDetailsElement | null
|
||||
if (target?.open) {
|
||||
void loadDiffFor(d)
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
|
||||
if (s === 'added') return 'green'
|
||||
if (s === 'removed') return 'red'
|
||||
if (s === 'conflict') return 'orange'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
added: Plus,
|
||||
removed: Minus,
|
||||
modified: Pencil,
|
||||
conflict: AlertTriangle
|
||||
}
|
||||
|
||||
// File tree built from the diff paths. Top-level rows mirror
|
||||
// WorkspaceItemDrillPicker: `f/foo` and `u/alice` collapse to a single
|
||||
// "scope" row, then deeper segments split per `/`. Leaves carry their
|
||||
// diff entry.
|
||||
type FolderNode = {
|
||||
type: 'folder'
|
||||
name: string
|
||||
fullPath: string
|
||||
isScope: boolean
|
||||
children: TreeNode[]
|
||||
}
|
||||
type FileNode = { type: 'file'; name: string; diff: WorkspaceItemDiff }
|
||||
type TreeNode = FolderNode | FileNode
|
||||
|
||||
function buildTree(diffs: WorkspaceItemDiff[]): FolderNode {
|
||||
const root: FolderNode = {
|
||||
type: 'folder',
|
||||
name: '',
|
||||
fullPath: '',
|
||||
isScope: false,
|
||||
children: []
|
||||
}
|
||||
const folderCache = new Map<string, FolderNode>()
|
||||
|
||||
for (const d of diffs) {
|
||||
const parts = d.path.split('/')
|
||||
if (parts.length < 2) {
|
||||
root.children.push({ type: 'file', name: d.path, diff: d })
|
||||
continue
|
||||
}
|
||||
const scopeKey = parts.slice(0, 2).join('/')
|
||||
let scope = folderCache.get(scopeKey)
|
||||
if (!scope) {
|
||||
scope = {
|
||||
type: 'folder',
|
||||
name: scopeKey,
|
||||
fullPath: scopeKey,
|
||||
isScope: true,
|
||||
children: []
|
||||
}
|
||||
folderCache.set(scopeKey, scope)
|
||||
root.children.push(scope)
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
scope.children.push({ type: 'file', name: scopeKey, diff: d })
|
||||
continue
|
||||
}
|
||||
const rest = parts.slice(2)
|
||||
let parent = scope
|
||||
let folderKey = scopeKey
|
||||
for (let i = 0; i < rest.length - 1; i++) {
|
||||
folderKey = `${folderKey}/${rest[i]}`
|
||||
let folder = folderCache.get(folderKey)
|
||||
if (!folder) {
|
||||
folder = {
|
||||
type: 'folder',
|
||||
name: rest[i],
|
||||
fullPath: folderKey,
|
||||
isScope: false,
|
||||
children: []
|
||||
}
|
||||
folderCache.set(folderKey, folder)
|
||||
parent.children.push(folder)
|
||||
}
|
||||
parent = folder
|
||||
}
|
||||
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
|
||||
}
|
||||
|
||||
const sortRec = (n: FolderNode) => {
|
||||
n.children.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
for (const c of n.children) if (c.type === 'folder') sortRec(c)
|
||||
}
|
||||
sortRec(root)
|
||||
return root
|
||||
}
|
||||
|
||||
// Searchable string per diff: path + summary (when loaded) + kind label.
|
||||
// SearchItems' uFuzzy runs fuzzy matching over these. Reads `summaries`
|
||||
// directly so the index re-derives as summaries trickle in from
|
||||
// loadDiffFor.
|
||||
function searchableText(d: WorkspaceItemDiff): string {
|
||||
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
|
||||
const s = summaries[itemKey(d)]
|
||||
if (s) parts.push(s)
|
||||
return parts.join(' ')
|
||||
}
|
||||
let searchedDiffs: (WorkspaceItemDiff & { marked?: string })[] | undefined = $state(undefined)
|
||||
|
||||
// Empty query bypasses SearchItems entirely so we don't wait a tick for
|
||||
// the async filter to run after open.
|
||||
const filteredDiffs = $derived.by(() => {
|
||||
const c = comparison
|
||||
if (!c) return [] as WorkspaceItemDiff[]
|
||||
const q = searchQuery.trim()
|
||||
if (!q) return c.diffs
|
||||
return (searchedDiffs ?? []) as WorkspaceItemDiff[]
|
||||
})
|
||||
|
||||
const tree = $derived.by(() => {
|
||||
const c = comparison
|
||||
return c ? buildTree(filteredDiffs) : undefined
|
||||
})
|
||||
|
||||
function rowId(d: WorkspaceItemDiff): string {
|
||||
return `fork-diff-${itemKey(d)}`
|
||||
}
|
||||
|
||||
function scrollToDiff(d: WorkspaceItemDiff) {
|
||||
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
|
||||
if (!el) return
|
||||
el.open = true
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
|
||||
// Per-folder open/closed state. Defaults to open; user toggles via the
|
||||
// <details> summary or via Enter when a folder row is highlighted.
|
||||
let folderOpen: Record<string, boolean> = $state({})
|
||||
function isFolderOpen(key: string): boolean {
|
||||
return folderOpen[key] ?? true
|
||||
}
|
||||
function folderKey(node: FolderNode): string {
|
||||
return `folder:${node.fullPath}`
|
||||
}
|
||||
|
||||
type NavEntry =
|
||||
| { type: 'folder'; key: string; node: FolderNode }
|
||||
| { type: 'file'; key: string; diff: WorkspaceItemDiff }
|
||||
|
||||
function flattenVisible(node: FolderNode): NavEntry[] {
|
||||
const out: NavEntry[] = []
|
||||
const walk = (n: TreeNode) => {
|
||||
if (n.type === 'file') {
|
||||
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
|
||||
return
|
||||
}
|
||||
const fkey = folderKey(n)
|
||||
out.push({ type: 'folder', key: fkey, node: n })
|
||||
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
|
||||
}
|
||||
for (const c of node.children) walk(c)
|
||||
return out
|
||||
}
|
||||
const navEntries = $derived(tree ? flattenVisible(tree) : [])
|
||||
const navKeys = $derived(navEntries.map((e) => e.key))
|
||||
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
|
||||
|
||||
let highlightedKey: string | undefined = $state(undefined)
|
||||
let mouseActive = $state(false)
|
||||
let searchInputEl: HTMLInputElement | undefined = $state()
|
||||
let sidebarRoot: HTMLElement | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (navKeys.length === 0) return
|
||||
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
|
||||
highlightedKey = navKeys[0]
|
||||
}
|
||||
})
|
||||
|
||||
function scrollHighlightIntoView() {
|
||||
if (!sidebarRoot || !highlightedKey) return
|
||||
const el = sidebarRoot.querySelector<HTMLElement>(
|
||||
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
|
||||
)
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function moveHighlight(delta: 1 | -1) {
|
||||
if (navKeys.length === 0) return
|
||||
const cur = navKeys.indexOf(highlightedKey ?? '')
|
||||
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
|
||||
highlightedKey = navKeys[next]
|
||||
mouseActive = false
|
||||
requestAnimationFrame(scrollHighlightIntoView)
|
||||
}
|
||||
|
||||
function setHoverHighlight(key: string) {
|
||||
// Same defense as the picker: ignore until the user actually moves the
|
||||
// mouse, so a cursor parked over a row doesn't clobber the keyboard
|
||||
// highlight when the layout shifts.
|
||||
if (mouseActive) highlightedKey = key
|
||||
}
|
||||
|
||||
function activateHighlighted() {
|
||||
if (!highlightedKey) return
|
||||
const entry = entryByKey.get(highlightedKey)
|
||||
if (!entry) return
|
||||
if (entry.type === 'file') {
|
||||
scrollToDiff(entry.diff)
|
||||
} else {
|
||||
folderOpen[entry.key] = !isFolderOpen(entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
// The folder containing an entry, as a folder key (or undefined if the
|
||||
// entry is at the top scope and has no parent folder). Pure logic lives in
|
||||
// forkDiffNav.parentFolderKey (unit-tested).
|
||||
function parentFolderKeyFor(entry: NavEntry): string | undefined {
|
||||
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
|
||||
return parentFolderKey(entry.type, path)
|
||||
}
|
||||
|
||||
function firstChildKey(node: FolderNode): string | undefined {
|
||||
const c = node.children[0]
|
||||
if (!c) return undefined
|
||||
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
|
||||
}
|
||||
|
||||
function selectKey(key: string) {
|
||||
highlightedKey = key
|
||||
mouseActive = false
|
||||
requestAnimationFrame(scrollHighlightIntoView)
|
||||
}
|
||||
|
||||
function handleSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
moveHighlight(1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
moveHighlight(-1)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
activateHighlighted()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// On a closed folder: open it. On an open folder: jump to its first
|
||||
// child (folder or file). On a file: no-op.
|
||||
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
|
||||
if (!entry || entry.type !== 'folder') return
|
||||
if (!isFolderOpen(entry.key)) {
|
||||
e.preventDefault()
|
||||
folderOpen[entry.key] = true
|
||||
return
|
||||
}
|
||||
const child = firstChildKey(entry.node)
|
||||
if (child) {
|
||||
e.preventDefault()
|
||||
selectKey(child)
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
// On an open folder: collapse it. On a closed folder (or a file):
|
||||
// jump to the parent folder.
|
||||
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
|
||||
if (!entry) return
|
||||
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
|
||||
e.preventDefault()
|
||||
folderOpen[entry.key] = false
|
||||
return
|
||||
}
|
||||
const parent = parentFolderKeyFor(entry)
|
||||
if (parent && entryByKey.has(parent)) {
|
||||
e.preventDefault()
|
||||
selectKey(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
filter={searchQuery}
|
||||
items={comparison?.diffs ?? []}
|
||||
bind:filteredItems={searchedDiffs}
|
||||
f={(d: WorkspaceItemDiff) => searchableText(d)}
|
||||
/>
|
||||
|
||||
{#snippet renderTreeNode(node: TreeNode, depth: number)}
|
||||
{#if node.type === 'folder'}
|
||||
{@const isUserScope = node.isScope && node.name.startsWith('u/')}
|
||||
{@const fkey = folderKey(node)}
|
||||
{@const open = isFolderOpen(fkey)}
|
||||
{@const isHl = fkey === highlightedKey}
|
||||
<details
|
||||
{open}
|
||||
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
|
||||
class="select-none"
|
||||
>
|
||||
<summary
|
||||
role="option"
|
||||
aria-selected={isHl}
|
||||
data-nav-key={fkey}
|
||||
onmouseenter={() => setHoverHighlight(fkey)}
|
||||
class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
style="padding-left: {depth * 12 + 8}px"
|
||||
>
|
||||
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
|
||||
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
|
||||
{#if isUserScope}
|
||||
<User size={12} class="shrink-0 text-tertiary" />
|
||||
{:else}
|
||||
<Folder size={12} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
<span class="truncate" title={node.name}>{node.name}</span>
|
||||
</summary>
|
||||
<div>
|
||||
{#each node.children as child}
|
||||
{@render renderTreeNode(child, depth + 1)}
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
{@const status = statusOf(node.diff)}
|
||||
{@const key = itemKey(node.diff)}
|
||||
<WorkspaceItemRow
|
||||
kind={node.diff.kind}
|
||||
summary={summaries[key]}
|
||||
secondary={node.name}
|
||||
highlighted={key === highlightedKey}
|
||||
navKey={key}
|
||||
indent={depth * 12 + 20}
|
||||
title={node.diff.path}
|
||||
onclick={() => {
|
||||
highlightedKey = key
|
||||
scrollToDiff(node.diff)
|
||||
}}
|
||||
onmouseenter={() => setHoverHighlight(key)}
|
||||
>
|
||||
{#snippet extras()}
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
|
||||
? 'bg-green-500'
|
||||
: status === 'removed'
|
||||
? 'bg-red-500'
|
||||
: status === 'conflict'
|
||||
? 'bg-orange-500'
|
||||
: 'bg-blue-500'}"
|
||||
></span>
|
||||
{/snippet}
|
||||
</WorkspaceItemRow>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Drawer bind:this={drawer} size="1200px">
|
||||
<DrawerContent
|
||||
title="Fork changes"
|
||||
on:close={() => drawer?.closeDrawer()}
|
||||
documentationLink={undefined}
|
||||
noPadding
|
||||
overflow_y={false}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary">
|
||||
<GitFork class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
|
||||
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
|
||||
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
|
||||
{#if comparison}
|
||||
<Badge color="transparent" class="ml-2">
|
||||
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{#if comparison.summary.conflicts > 0}
|
||||
<Badge color="orange">
|
||||
<AlertTriangle class="w-3 h-3 inline mr-1" />
|
||||
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="sbs"
|
||||
label="Side-by-side"
|
||||
icon={SquareSplitHorizontal}
|
||||
tooltip="Side-by-side diff"
|
||||
iconOnly
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="inline"
|
||||
label="Unified"
|
||||
icon={DiffIcon}
|
||||
tooltip="Unified diff"
|
||||
iconOnly
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: GitMerge }} onclick={openReview}>
|
||||
Review
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="flex flex-row h-full min-h-0">
|
||||
{#if comparison && comparison.diffs.length > 0}
|
||||
<aside
|
||||
bind:this={sidebarRoot}
|
||||
onmousemove={() => (mouseActive = true)}
|
||||
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
|
||||
>
|
||||
<div class="px-3 pt-3 pb-2 shrink-0">
|
||||
<input
|
||||
bind:this={searchInputEl}
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Filter files..."
|
||||
onkeydown={handleSearchKeydown}
|
||||
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
|
||||
{#if tree && tree.children.length > 0}
|
||||
{#each tree.children as child}
|
||||
{@render renderTreeNode(child, 0)}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
<main class="flex-1 min-w-0 overflow-y-auto">
|
||||
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
|
||||
{#if loading && !comparison}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
Loading comparison...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
|
||||
{:else if comparison?.skipped_comparison}
|
||||
<div class="text-sm text-secondary py-4">
|
||||
This fork was created before change tracking was added — diffs are not available.
|
||||
</div>
|
||||
{:else if comparison && comparison.diffs.length === 0}
|
||||
<div class="text-sm text-secondary py-4"
|
||||
>No changes between this fork and its parent.</div
|
||||
>
|
||||
{:else if comparison && filteredDiffs.length === 0}
|
||||
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
|
||||
{:else if comparison}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each filteredDiffs as d (itemKey(d))}
|
||||
{@const key = itemKey(d)}
|
||||
{@const status = statusOf(d)}
|
||||
{@const StatusIcon = statusIcons[status]}
|
||||
{@const loaded = loadedDiffs[key]}
|
||||
{@const editUrl = editUrlFor(d)}
|
||||
<details
|
||||
open
|
||||
id={rowId(d)}
|
||||
class="border border-light rounded-md bg-surface scroll-mt-2"
|
||||
ontoggle={(e) => onDetailsToggle(d, e)}
|
||||
>
|
||||
<summary
|
||||
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
|
||||
>
|
||||
<ChevronDown
|
||||
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
|
||||
/>
|
||||
<RowIcon kind={d.kind} size={14} />
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editUrl}
|
||||
<a
|
||||
href={editUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={d.path}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="group inline-flex items-center gap-1 max-w-full text-xs text-primary font-mono truncate hover:underline"
|
||||
>
|
||||
<span class="truncate">{d.path}</span>
|
||||
<ExternalLink
|
||||
class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="text-xs text-primary font-mono truncate" title={d.path}>
|
||||
{d.path}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{#if d.ahead > 0}
|
||||
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
|
||||
{/if}
|
||||
{#if d.behind > 0}
|
||||
<span class="text-2xs text-secondary">{d.behind} behind</span>
|
||||
{/if}
|
||||
<Badge color={statusBadgeColor(status)}>
|
||||
<StatusIcon class="w-3 h-3 inline mr-0.5" />
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
</summary>
|
||||
<div
|
||||
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
|
||||
>
|
||||
{#if !loaded || loaded.state === 'loading'}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary p-3">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
Loading diff…
|
||||
</div>
|
||||
{:else if loaded.state === 'error'}
|
||||
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
|
||||
{:else if loaded.state === 'ready'}
|
||||
<WorkspaceItemDiffViewer
|
||||
kind={d.kind}
|
||||
originalRaw={loaded.parentRaw}
|
||||
currentRaw={loaded.forkRaw}
|
||||
{inlineDiff}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div></main
|
||||
></div
|
||||
>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
/* Diff rows use a ChevronDown; rotate it back when collapsed. */
|
||||
details:not([open]) :global(.chevron) {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
/* Tree folder rows: swap chevrons based on the folder's open state. */
|
||||
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
|
||||
display: none;
|
||||
}
|
||||
details[open] > .tree-summary :global(.tree-chevron-closed) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { untrack } from 'svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import type { RawAppDraft } from './appDraftCodec'
|
||||
import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft } from './appDraftCodec'
|
||||
import SessionItemNotFound from './SessionItemNotFound.svelte'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
isActiveSession = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
path: string
|
||||
workspaceId: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
/**
|
||||
* Only the visible session should claim the workspace's live-editor
|
||||
* slot — without this, a hidden warm-mounted session can overwrite the
|
||||
* active session's UserDraft live-editor target (one slot per
|
||||
* (workspace, kind)), so chat actions like discard / "the open editor"
|
||||
* resolve to the wrong session.
|
||||
*/
|
||||
isActiveSession?: boolean
|
||||
} = $props()
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (workspaceId && path) {
|
||||
untrack(() => runtime.loadRawApp(workspaceId, path))
|
||||
}
|
||||
})
|
||||
|
||||
async function restoreFromCurrentTarget() {
|
||||
diffDrawer?.closeDrawer()
|
||||
await runtime.loadRawApp(workspaceId, path)
|
||||
}
|
||||
|
||||
// Mark this editor as the live editor draft for the session's workspace
|
||||
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
|
||||
// to this path — same registration the regular /apps_raw/edit page does.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
// claim the workspace's single live-editor slot, else chat actions on the
|
||||
// visible session resolve to the hidden one's path.
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (!isActiveSession) return
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: workspaceId,
|
||||
itemKind: 'raw_app',
|
||||
storagePath: path,
|
||||
effectivePath: runtime.rawApp.val?.path ?? path
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('raw_app', { workspace: workspaceId, storagePath: path })
|
||||
})
|
||||
|
||||
// Bidirectional sync between this preview and `UserDraft<RawAppDraft>`.
|
||||
// We hold a *live* handle (useMany) rather than reading via the static
|
||||
// `UserDraft.get`: the handle materializes UserDraft's shared reactive
|
||||
// `$state` cell for (workspace, 'raw_app', path), and that cell is what
|
||||
// lets the chat's writes (UserDraft.save / setDraftAndMeta, from
|
||||
// write_app_file / patch_app_file / write_app_runnable) reach this preview.
|
||||
// Without a live entry those writes only touch localStorage and the inbound
|
||||
// effect below never re-fires. A reactive getter is used (not `use()`)
|
||||
// because switching open_preview to another app swaps `path` without
|
||||
// remounting this view, so the handle must re-acquire.
|
||||
//
|
||||
// Same one-way-reactive discipline as ScriptEditorView: inbound tracks only
|
||||
// the handle's draft, outbound tracks only rawApp.val; each side's read of
|
||||
// the other goes through untrack() to break the keystroke-revert race.
|
||||
const draftHandles = UserDraft.useMany<RawAppDraft>(() => [
|
||||
{ itemKind: 'raw_app', path, workspace: workspaceId }
|
||||
])
|
||||
let lastInboundSig: string | undefined = $state(undefined)
|
||||
|
||||
// Store → editor. Re-runs when the handle's draft changes (chat write,
|
||||
// other session edit).
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
const incoming = draftHandles[0]?.draft
|
||||
if (!incoming) return
|
||||
const sig = JSON.stringify(incoming)
|
||||
untrack(() => {
|
||||
if (runtime.loadedRawAppPath !== path) return
|
||||
if (sig === lastInboundSig) return
|
||||
const current = runtime.rawApp.val
|
||||
if (!current) return
|
||||
lastInboundSig = sig
|
||||
runtime.rawApp.val = applyDraftToRuntimeRawApp(current, incoming)
|
||||
})
|
||||
})
|
||||
|
||||
// Editor → store. Debounced 150ms so a typing burst inside a frontend
|
||||
// file's Monaco editor coalesces into one store write.
|
||||
let outboundTimer: ReturnType<typeof setTimeout> | undefined
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (runtime.loadedRawAppPath !== path) return
|
||||
const raw = runtime.rawApp.val
|
||||
if (!raw) return
|
||||
const draft = runtimeRawAppToDraft(raw)
|
||||
const sig = JSON.stringify(draft)
|
||||
if (sig === lastInboundSig) return
|
||||
if (outboundTimer) clearTimeout(outboundTimer)
|
||||
outboundTimer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
const current = UserDraft.get<RawAppDraft>('raw_app', path, { workspace: workspaceId })
|
||||
if (current && JSON.stringify(current) === sig) return
|
||||
UserDraft.save('raw_app', path, draft, { workspace: workspaceId })
|
||||
})
|
||||
}, 150)
|
||||
return () => {
|
||||
if (outboundTimer) clearTimeout(outboundTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if runtime.savedRawApp.val}
|
||||
<DiffDrawer
|
||||
bind:this={diffDrawer}
|
||||
restoreDeployed={restoreFromCurrentTarget}
|
||||
restoreDraft={restoreFromCurrentTarget}
|
||||
/>
|
||||
{/if}
|
||||
{#if runtime.loadingRawApp && !runtime.loadedRawAppPath}
|
||||
<div class="p-4 text-secondary text-sm">Loading raw app {path}…</div>
|
||||
{:else if runtime.notFoundRawApp && !runtime.loadedRawAppPath}
|
||||
<SessionItemNotFound kind="raw_app" {path} {onNavigate} />
|
||||
{:else if runtime.rawApp.val}
|
||||
<RawAppEditor
|
||||
bind:files={runtime.rawApp.val.files}
|
||||
bind:runnables={runtime.rawApp.val.runnables}
|
||||
bind:data={runtime.rawApp.val.data}
|
||||
bind:summary={runtime.rawApp.val.summary}
|
||||
newPath={runtime.rawApp.val.path}
|
||||
{path}
|
||||
policy={runtime.rawApp.val.policy}
|
||||
bind:savedApp={runtime.savedRawApp.val}
|
||||
newApp={!runtime.savedRawApp.val}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
onDeploy={(e) => {
|
||||
// Sync the preview to deployed (raw apps deploy only from this editor).
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
|
||||
}}
|
||||
defaultSidebarCollapsed
|
||||
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
|
||||
defaultSplitWithPreview={false}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { WorkspaceItem } from '$lib/components/workspacePicker'
|
||||
import { untrack } from 'svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { DraftService, ScriptService, type NewScript } from '$lib/gen'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import SessionItemNotFound from './SessionItemNotFound.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
path,
|
||||
workspaceId,
|
||||
onNavigate,
|
||||
initialTestPanelCollapsed = false,
|
||||
isActiveSession = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
path: string
|
||||
workspaceId: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
initialTestPanelCollapsed?: boolean
|
||||
/**
|
||||
* Only the visible session should claim the workspace's live-editor
|
||||
* slot — without this, a hidden warm-mounted session can overwrite the
|
||||
* active session's UserDraft live-editor target (one slot per
|
||||
* (workspace, kind)), so chat actions like discard / "the open editor"
|
||||
* resolve to the wrong session.
|
||||
*/
|
||||
isActiveSession?: boolean
|
||||
} = $props()
|
||||
|
||||
let diffDrawer: DiffDrawer | undefined = $state()
|
||||
|
||||
$effect(() => {
|
||||
if (workspaceId && path) {
|
||||
untrack(() => runtime.loadScript(workspaceId, path))
|
||||
}
|
||||
})
|
||||
|
||||
// Restore actions for the diff drawer. The previous shared
|
||||
// `loadScript`-based handler was a no-op: loadScript early-returns on the
|
||||
// already-loaded path (and would re-read the local draft anyway). Instead
|
||||
// reset the live UserDraft handle to the target baseline — the inbound
|
||||
// effect then syncs the editor preview. Mirrors /scripts/edit's restore.
|
||||
async function restoreDeployed() {
|
||||
const saved = runtime.savedScript.val
|
||||
if (!saved) {
|
||||
sendUserToast('Could not restore to deployed', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
// Drop the backend (DB) draft too, so "deployed" sticks across a reload.
|
||||
if (saved.draft) {
|
||||
try {
|
||||
await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path })
|
||||
saved.draft = undefined
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown }
|
||||
delete deployed.draft
|
||||
UserDraft.discard<NewScript>('script', path, deployed, { workspace: workspaceId })
|
||||
}
|
||||
|
||||
async function restoreDraft() {
|
||||
const backendDraft = runtime.savedScript.val?.draft as NewScript | undefined
|
||||
if (!backendDraft) {
|
||||
sendUserToast('Could not restore to draft', true)
|
||||
return
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.discard<NewScript>('script', path, structuredClone($state.snapshot(backendDraft)), {
|
||||
workspace: workspaceId
|
||||
})
|
||||
}
|
||||
|
||||
// Mark this editor as the live editor draft for the session's workspace
|
||||
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
|
||||
// to this path — same registration the regular /scripts/edit page does.
|
||||
// Gated on `isActiveSession`: warm-but-hidden session editors must not
|
||||
// claim the workspace's single live-editor slot, else chat actions on the
|
||||
// visible session resolve to the hidden one's path.
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (!isActiveSession) return
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: workspaceId,
|
||||
itemKind: 'script',
|
||||
storagePath: path,
|
||||
effectivePath: runtime.scriptStore.val?.path ?? path
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path })
|
||||
})
|
||||
|
||||
// Bidirectional sync between this preview and `UserDraft<NewScript>`.
|
||||
// The same path under the same workspace is shared with the session's
|
||||
// chat (read_workspace_item / write_script / edit_script) and any other
|
||||
// open editor on the same workspace.
|
||||
//
|
||||
// We hold a *live* handle (useMany) instead of reading via the static
|
||||
// `UserDraft.get`. The handle materializes UserDraft's shared reactive
|
||||
// `$state` cell for (workspace, 'script', path) — and that cell is what
|
||||
// lets the chat's writes (UserDraft.save, from write_script / edit_script)
|
||||
// reach this preview. Without a live entry those writes only touch
|
||||
// localStorage and the inbound effect below never re-fires. A reactive
|
||||
// getter is used (not `use()`) because switching open_preview to another
|
||||
// script swaps `path` without remounting this view, so the handle must
|
||||
// re-acquire.
|
||||
//
|
||||
// One-way-reactive discipline: inbound tracks ONLY the handle's `draft`
|
||||
// (and reads `script.content` via untrack); outbound tracks ONLY
|
||||
// `script.content` (and reads UserDraft via untrack). Without that
|
||||
// asymmetry, a user keystroke would re-fire the inbound effect with the
|
||||
// pre-keystroke stored value and revert the edit.
|
||||
const draftHandles = UserDraft.useMany<NewScript>(() => [
|
||||
{ itemKind: 'script', path, workspace: workspaceId }
|
||||
])
|
||||
let lastInboundContent: string | undefined = $state(undefined)
|
||||
|
||||
// Store → editor. Re-runs when the handle's draft changes (chat write,
|
||||
// other session edit, …). `script.content` is read inside untrack so user
|
||||
// keystrokes don't refire this effect.
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
const draft = draftHandles[0]?.draft
|
||||
if (!draft || typeof draft.content !== 'string') return
|
||||
const incoming = draft.content
|
||||
untrack(() => {
|
||||
if (runtime.loadedScriptPath !== path) return
|
||||
const script = runtime.scriptStore.val
|
||||
if (!script) return
|
||||
if (incoming === script.content) return
|
||||
lastInboundContent = incoming
|
||||
script.content = incoming
|
||||
if (draft.language) script.language = draft.language
|
||||
if (draft.summary !== undefined) script.summary = draft.summary
|
||||
})
|
||||
})
|
||||
|
||||
// Editor → store. Re-runs on `script.content` mutation (user typing
|
||||
// or inbound write). UserDraft is read inside untrack so writing here
|
||||
// doesn't ping-pong the inbound effect. `UserDraft.save` persists
|
||||
// immediately and, now that the entry is live, updates the same cell the
|
||||
// inbound effect reads (the content guard there makes it a no-op).
|
||||
$effect(() => {
|
||||
if (!workspaceId || !path) return
|
||||
if (runtime.loadedScriptPath !== path) return
|
||||
const script = runtime.scriptStore.val
|
||||
if (!script) return
|
||||
const content = script.content
|
||||
if (content === lastInboundContent) return
|
||||
untrack(() => {
|
||||
const current = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
|
||||
if (current && current.content === content) return
|
||||
UserDraft.save<NewScript>(
|
||||
'script',
|
||||
path,
|
||||
{ ...(current ?? script), ...script },
|
||||
{
|
||||
workspace: workspaceId
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if runtime.savedScript.val}
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
|
||||
{/if}
|
||||
{#if runtime.loadingScript && !runtime.loadedScriptPath}
|
||||
<div class="p-4 text-secondary text-sm">Loading script {path}…</div>
|
||||
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
|
||||
<SessionItemNotFound kind="script" {path} {onNavigate} />
|
||||
{:else if runtime.scriptStore.val}
|
||||
<!--
|
||||
A script with no backend version yet (AI-created, never saved or deployed
|
||||
→ savedScript undefined) is a *new* script: pass an empty initialPath so
|
||||
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
|
||||
creates it on first save. On that save ScriptBuilder writes savedScript back
|
||||
through the bind and sets its own initialPath to the path, flipping us into
|
||||
edit mode (Save draft + Show diff) without navigating away.
|
||||
-->
|
||||
<ScriptBuilder
|
||||
bind:script={runtime.scriptStore.val}
|
||||
bind:savedScript={runtime.savedScript.val}
|
||||
initialPath={runtime.savedScript.val ? path : ''}
|
||||
initialPathChosen={true}
|
||||
neverShowMeta={true}
|
||||
fullyLoaded={!runtime.loadingScript}
|
||||
disableHistoryChange={true}
|
||||
{diffDrawer}
|
||||
{onNavigate}
|
||||
{initialTestPanelCollapsed}
|
||||
onSaveDraft={async (e) => {
|
||||
runtime.scheduleForkComparisonRefresh()
|
||||
// Re-pin parent_hash to the latest version so the next Deploy's conflict
|
||||
// check (which runs before deploy, while the session stays mounted)
|
||||
// doesn't misfire.
|
||||
try {
|
||||
const latest = await ScriptService.getScriptLatestVersion({
|
||||
workspace: workspaceId,
|
||||
path: e.path
|
||||
})
|
||||
const cur = runtime.scriptStore.val
|
||||
if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash
|
||||
} catch (err) {
|
||||
console.error('Failed to sync parent_hash after save draft', err)
|
||||
}
|
||||
}}
|
||||
onDeploy={(e) => {
|
||||
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
|
||||
// ignore e.stay since the session always stays). Toast, then sync the
|
||||
// preview to the deployed version.
|
||||
sendUserToast('Deployed')
|
||||
runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Archive,
|
||||
ArrowRight,
|
||||
GitCompareArrows,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
GitPullRequestClosed,
|
||||
MoveRight,
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte'
|
||||
import { getRuntime } from './sessionRuntime.svelte'
|
||||
import ForkDiffDrawer from './ForkDiffDrawer.svelte'
|
||||
|
||||
let {
|
||||
session,
|
||||
onMove,
|
||||
onCreateForkAndMove,
|
||||
onArchive,
|
||||
onDelete
|
||||
}: {
|
||||
session: Session
|
||||
onMove?: (workspaceId: string) => void
|
||||
onCreateForkAndMove?: (fork: {
|
||||
parent_workspace_id: string
|
||||
id: string
|
||||
name: string
|
||||
}) => void | Promise<void>
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
} = $props()
|
||||
|
||||
// The fork bar surfaces a committed workspace relationship — only
|
||||
// visible after the session locked its workspace at first send. Drafts
|
||||
// (workspace_id undefined) get nothing here.
|
||||
const committedId = $derived(session.workspace_id)
|
||||
const sessionWorkspace = $derived(
|
||||
committedId ? $userWorkspaces.find((w) => w.id === committedId) : undefined
|
||||
)
|
||||
const parentWorkspaceId = $derived(sessionWorkspace?.parent_workspace_id ?? undefined)
|
||||
const parentWorkspace = $derived(
|
||||
parentWorkspaceId ? $userWorkspaces.find((w) => w.id === parentWorkspaceId) : undefined
|
||||
)
|
||||
const isFork = $derived(!!parentWorkspaceId)
|
||||
|
||||
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
|
||||
// When forking isn't available the diff/review surface is moot.
|
||||
const forksAllowed = $derived(
|
||||
!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') && $workspaceStore !== 'admins'
|
||||
)
|
||||
|
||||
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
|
||||
|
||||
// Comparison data lives on the shared SessionRuntime resource so any
|
||||
// future consumer (e.g. the diff drawer, a merge action) reads the
|
||||
// same cache and can invalidate it after mutating the fork.
|
||||
const runtime = $derived(getRuntime(session.id))
|
||||
const comparison = $derived(runtime?.forkComparison.val)
|
||||
const totalDiffs = $derived(comparison?.summary?.total_diffs ?? 0)
|
||||
const forkStatus = $derived(deriveForkStatus(session, $userWorkspaces, comparison))
|
||||
const isUnavailable = $derived(forkStatus === 'unavailable')
|
||||
|
||||
$effect(() => {
|
||||
if (!runtime || !committedId || !parentWorkspaceId) return
|
||||
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
|
||||
})
|
||||
|
||||
function refreshComparison() {
|
||||
if (!runtime || !committedId || !parentWorkspaceId) return
|
||||
runtime.invalidateForkComparison()
|
||||
void runtime.ensureForkComparison(parentWorkspaceId, committedId)
|
||||
}
|
||||
|
||||
// Refresh when the AI finishes a turn (loading transitions true →
|
||||
// false). Tool calls in that turn may have created / edited / deleted
|
||||
// fork items, so the diff count needs to reflect them immediately.
|
||||
let wasLoading = $state(false)
|
||||
$effect(() => {
|
||||
const isLoading = runtime?.manager.loading ?? false
|
||||
if (wasLoading && !isLoading) refreshComparison()
|
||||
wasLoading = isLoading
|
||||
})
|
||||
|
||||
// Refresh when the tab regains visibility — covers edits made in
|
||||
// another tab or by another user while we were away.
|
||||
$effect(() => {
|
||||
if (!runtime || !committedId || !parentWorkspaceId) return
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (sessionState.currentSessionId !== session.id) return
|
||||
refreshComparison()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
export const refresh = refreshComparison
|
||||
|
||||
function openReview() {
|
||||
if (!committedId || isUnavailable) return
|
||||
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if committedId && isUnavailable}
|
||||
<!-- Fork workspace is no longer in the user's list (deleted, archived,
|
||||
or access revoked). Surface an actionable banner: move the session
|
||||
to a still-valid workspace, or discard it (archive / delete). The
|
||||
chat input is disabled by SessionWrapper while this is shown. -->
|
||||
<div class="flex flex-col gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary">
|
||||
<div class="flex flex-row items-start gap-2">
|
||||
<GitPullRequestClosed class="w-4 h-4 shrink-0 text-tertiary mt-0.5" />
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-primary font-medium">The fork has been archived or deleted</span>
|
||||
<span class="text-2xs text-tertiary">
|
||||
Move this session to another workspace, or discard it.
|
||||
<span class="font-mono text-tertiary" title={committedId}>{committedId}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-end gap-1.5">
|
||||
<WorkspaceFamilyPicker
|
||||
onPick={(workspaceId) => onMove?.(workspaceId)}
|
||||
onCreateFork={async (fork) => {
|
||||
await onCreateForkAndMove?.(fork)
|
||||
}}
|
||||
createForkCaption="Created immediately and the session moved into it."
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button variant="default" unifiedSize="sm" startIcon={{ icon: MoveRight }}>
|
||||
Move to workspace
|
||||
</Button>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Archive }}
|
||||
dropdownItems={[{ label: 'Delete', icon: Trash2, onClick: () => onDelete?.() }]}
|
||||
onclick={() => onArchive?.()}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId}
|
||||
{@const StatusIcon =
|
||||
forkStatus === 'ahead'
|
||||
? GitPullRequestArrow
|
||||
: forkStatus === 'diverged'
|
||||
? GitCompareArrows
|
||||
: GitFork}
|
||||
{@const statusColor =
|
||||
forkStatus === 'ahead'
|
||||
? 'text-blue-500'
|
||||
: forkStatus === 'diverged'
|
||||
? 'text-amber-500'
|
||||
: 'text-secondary'}
|
||||
{@const statusTitle =
|
||||
forkStatus === 'ahead'
|
||||
? 'Ahead of parent'
|
||||
: forkStatus === 'diverged'
|
||||
? 'Diverged from parent'
|
||||
: forkStatus === 'in_sync'
|
||||
? 'In sync with parent'
|
||||
: 'Fork'}
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span title={statusTitle} class="inline-flex shrink-0">
|
||||
<StatusIcon class="w-3.5 h-3.5 {statusColor}" />
|
||||
</span>
|
||||
<span class="truncate text-secondary" title={sessionWorkspace.name}>
|
||||
{sessionWorkspace.name}
|
||||
</span>
|
||||
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
|
||||
<span class="truncate text-secondary" title={parentWorkspace.name}>
|
||||
{parentWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: GitCompareArrows }}
|
||||
disabled={totalDiffs === 0}
|
||||
title="{totalDiffs} modified item{totalDiffs === 1 ? '' : 's'}"
|
||||
onclick={() => diffDrawer?.open()}
|
||||
>
|
||||
{totalDiffs}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: GitMerge }}
|
||||
onclick={openReview}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForkDiffDrawer bind:this={diffDrawer} forkWorkspaceId={committedId} {parentWorkspaceId} />
|
||||
{/if}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import type { WorkspaceItem, WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
|
||||
const KIND_NOT_FOUND_LABEL: Record<SessionTarget['kind'], string> = {
|
||||
flow: 'Flow',
|
||||
script: 'Script',
|
||||
raw_app: 'Raw app'
|
||||
}
|
||||
|
||||
let {
|
||||
kind,
|
||||
path,
|
||||
onNavigate
|
||||
}: {
|
||||
kind: SessionTarget['kind']
|
||||
path: string
|
||||
onNavigate?: (item: WorkspaceItem) => void
|
||||
} = $props()
|
||||
|
||||
// EditorHeader's `kind` prop is 'flow' | 'script' | 'app'; raw apps are
|
||||
// flagged via the `raw_app` boolean so the picker routes to /apps_raw/...
|
||||
const headerKind: WorkspaceItemKind = $derived(kind === 'raw_app' ? 'app' : kind)
|
||||
const isRawApp = $derived(kind === 'raw_app')
|
||||
|
||||
// Local read-only mirrors so EditorHeader's bind:* doesn't write back into
|
||||
// the parent's session state. The pen popover is disabled below so these
|
||||
// stay quiet — but bindable references are still required by the API.
|
||||
let summary = $state('')
|
||||
let displayPath = $state(path)
|
||||
$effect(() => {
|
||||
displayPath = path
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex h-12 items-center px-4 border-b border-border-light">
|
||||
<EditorHeader
|
||||
bind:summary
|
||||
bind:path={displayPath}
|
||||
savedPath={path}
|
||||
kind={headerKind}
|
||||
raw_app={isRawApp}
|
||||
summaryEditable={false}
|
||||
pathEditable={false}
|
||||
{onNavigate}
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4 text-secondary text-sm">
|
||||
{KIND_NOT_FOUND_LABEL[kind]} not found at path
|
||||
<code class="font-mono">{path}</code>.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,629 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
EllipsisVertical,
|
||||
Filter,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import {
|
||||
createSession,
|
||||
deriveForkStatus,
|
||||
getEffectiveWorkspaceId,
|
||||
isForkSession,
|
||||
renameSession,
|
||||
selectSession,
|
||||
sessionState,
|
||||
setSessionArchived,
|
||||
syncWorkspaceTo,
|
||||
type Session
|
||||
} from './sessionState.svelte'
|
||||
import { forgetSessionSeen, unreadCountFor } from './sessionUnread.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
getSessionChatStatus,
|
||||
removeSession
|
||||
} from './sessionRuntime.svelte'
|
||||
import SessionStatusDot from './SessionStatusDot.svelte'
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { visibleWorkspaceIds } from './sessionScope.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
// Look up the cached fork comparison for a session through its runtime
|
||||
// (if any). The deriveForkStatus helper handles the "no runtime yet"
|
||||
// and "comparison not loaded" cases by returning undefined; we render
|
||||
// a neutral fork icon in that interim, then upgrade to the proper
|
||||
// status icon once the comparison lands.
|
||||
function forkStatusFor(session: Session) {
|
||||
return deriveForkStatus(session, $userWorkspaces, getRuntime(session.id)?.forkComparison.val)
|
||||
}
|
||||
|
||||
function isForkFor(session: Session): boolean {
|
||||
return isForkSession(session, $userWorkspaces)
|
||||
}
|
||||
|
||||
// Compute the unread count for a session. Driven by the per-runtime
|
||||
// displayMessages array vs. the localStorage-backed lastSeen map;
|
||||
// both are reactive so the badge updates without polling.
|
||||
function unreadFor(session: Session): number {
|
||||
return unreadCountFor(session.id, getRuntime(session.id))
|
||||
}
|
||||
|
||||
// Whether the composer for a session holds non-whitespace text. We
|
||||
// read manager.instructions directly (not the derived chat status)
|
||||
// so the draft cue still shows during streaming/needs-confirmation —
|
||||
// those override the icon slot but shouldn't hide the fact that the
|
||||
// user has unsent text in this session.
|
||||
function hasDraft(session: Session): boolean {
|
||||
const rt = getRuntime(session.id)
|
||||
return !!rt && rt.manager.instructions.trim().length > 0
|
||||
}
|
||||
|
||||
// Sessions piggyback on the same dev gate as the global AI chat — when
|
||||
// the feature flag is off, the sidebar section is hidden entirely.
|
||||
const globalEnabled = isGlobalAiEnabled()
|
||||
|
||||
// Only highlight the active session while we're actually on the session
|
||||
// page — once the user navigates away, `currentSessionId` lingers but no
|
||||
// row should appear selected.
|
||||
const onSessionsPage = $derived(page.route.id?.includes('/sessions') ?? false)
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
}
|
||||
|
||||
let { isCollapsed = false }: Props = $props()
|
||||
|
||||
const sectionCollapsed = useLocalStorageValue(
|
||||
'windmill_sessions_section_collapsed',
|
||||
false,
|
||||
'boolean'
|
||||
)
|
||||
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
|
||||
|
||||
let listRoot: HTMLDivElement | undefined = $state()
|
||||
|
||||
// Sessions visible in the current workspace (active workspace + its
|
||||
// forks). Drafts (no committed workspace) are scoped by their
|
||||
// pending workspace pick — set at create time to the workspace the
|
||||
// user was in. Archived sessions are filtered out unless the user
|
||||
// has opted in via the filter popover.
|
||||
const visibleSessions = $derived(
|
||||
sessionState.sessions.filter((s) => {
|
||||
// Transient (not-yet-sent) sessions live as their own page but
|
||||
// don't clutter the sidebar list.
|
||||
if (s.transient) return false
|
||||
if (s.archived && !showArchived.val) return false
|
||||
const ws = getEffectiveWorkspaceId(s)
|
||||
if (!ws) return false
|
||||
if ($visibleWorkspaceIds.has(ws)) return true
|
||||
// Unavailable sessions (committed workspace was deleted /
|
||||
// archived / access revoked) stay visible everywhere so the
|
||||
// user can resolve them — move, archive, or delete. They'd
|
||||
// otherwise be permanently hidden the moment their workspace
|
||||
// disappeared.
|
||||
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
|
||||
return false
|
||||
})
|
||||
)
|
||||
const archivedCount = $derived(
|
||||
sessionState.sessions.filter((s) => {
|
||||
if (!s.archived || s.transient) return false
|
||||
const ws = getEffectiveWorkspaceId(s)
|
||||
if (!ws) return false
|
||||
if ($visibleWorkspaceIds.has(ws)) return true
|
||||
if (s.workspace_id && !$userWorkspaces.find((w) => w.id === s.workspace_id)) return true
|
||||
return false
|
||||
}).length
|
||||
)
|
||||
|
||||
// Sum of unread across every visible session — surfaced on the
|
||||
// collapsed-sidebar chat icon so the user sees there's pending
|
||||
// AI activity in some session without expanding the sidebar.
|
||||
const totalUnread = $derived(visibleSessions.reduce((acc, s) => acc + unreadFor(s), 0))
|
||||
|
||||
// Clear any persisted collapsed state while the list is empty. The
|
||||
// empty-state header is a plain label with no toggle, so a collapse
|
||||
// carried over from a previous session (or another workspace) would
|
||||
// otherwise hide the user's first new session with no way to expand
|
||||
// it. Resetting here keeps the section expanded by default whenever
|
||||
// the first session arrives. Guarded on the current value so it writes
|
||||
// once (true → false) rather than looping.
|
||||
$effect(() => {
|
||||
if (visibleSessions.length === 0 && sectionCollapsed.val) {
|
||||
sectionCollapsed.val = false
|
||||
}
|
||||
})
|
||||
|
||||
// Eagerly create a runtime per VISIBLE session so the status dot reflects
|
||||
// the persisted chat (last message, pending confirmation, etc.) without
|
||||
// requiring the user to open the session first. Sessions outside the
|
||||
// current workspace scope are left cold to avoid opening IDB connections
|
||||
// for unrelated work.
|
||||
$effect(() => {
|
||||
for (const session of visibleSessions) {
|
||||
getOrCreateRuntime(session)
|
||||
}
|
||||
})
|
||||
|
||||
// Pre-fetch the fork comparison for every visible fork session so the
|
||||
// sidebar icons reflect the right ahead/diverged state without
|
||||
// requiring the user to click into each session. Cheap enough at
|
||||
// typical session counts; falls back to a plain dot until the
|
||||
// fetch lands.
|
||||
$effect(() => {
|
||||
if (sectionCollapsed.val) return
|
||||
for (const session of visibleSessions) {
|
||||
if (!session.workspace_id) continue
|
||||
const ws = $userWorkspaces.find((w) => w.id === session.workspace_id)
|
||||
if (!ws?.parent_workspace_id) continue
|
||||
const rt = getRuntime(session.id)
|
||||
if (!rt) continue
|
||||
void rt.ensureForkComparison(ws.parent_workspace_id, session.workspace_id)
|
||||
}
|
||||
})
|
||||
|
||||
function isUnavailableFork(session: Session): boolean {
|
||||
return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id)
|
||||
}
|
||||
|
||||
async function activate(session: Session, restoreFocus: boolean = false) {
|
||||
selectSession(session.id)
|
||||
// If the session has a committed workspace different from the
|
||||
// active one, switch globally so the editor/forks resolve correctly.
|
||||
// Skip for unavailable forks — switching to a deleted workspace
|
||||
// would error out and leave the user in limbo.
|
||||
if (!isUnavailableFork(session)) {
|
||||
syncWorkspaceTo(session.workspace_id)
|
||||
}
|
||||
// Refresh the fork diff count — users typically click back into a
|
||||
// session after editing items elsewhere in the SPA, where neither
|
||||
// the visibility-change nor the AI-loading signal would fire.
|
||||
void getRuntime(session.id)?.refreshForkComparison()
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
|
||||
if (restoreFocus) {
|
||||
// goto() resets focus to <body> — put it back on the active session button
|
||||
// so subsequent arrow keys keep navigating the list.
|
||||
requestAnimationFrame(() => {
|
||||
const selected = listRoot?.querySelector<HTMLButtonElement>(
|
||||
'button[data-session-button][aria-selected="true"]'
|
||||
)
|
||||
selected?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndOpen() {
|
||||
await activate(createSession())
|
||||
}
|
||||
|
||||
let editingId: string | undefined = $state(undefined)
|
||||
let renameDraft = $state('')
|
||||
|
||||
function startRename(session: Session) {
|
||||
editingId = session.id
|
||||
renameDraft = session.summary ?? ''
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
const id = editingId
|
||||
if (!id) return
|
||||
renameSession(id, renameDraft)
|
||||
editingId = undefined
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId = undefined
|
||||
}
|
||||
|
||||
let pendingDelete: Session | undefined = $state(undefined)
|
||||
// Default to also deleting the fork: it's tied to this session and would be
|
||||
// orphaned otherwise. The user can still untick it in the modal.
|
||||
let deleteAlsoFork = $state(true)
|
||||
// Fork workspace tied to `pendingDelete`, if any, and still accessible.
|
||||
const pendingDeleteForkId = $derived.by(() => {
|
||||
const wsId = pendingDelete?.workspace_id
|
||||
if (!wsId || !wsId.startsWith('wm-fork-')) return undefined
|
||||
const ws = $userWorkspaces.find((w) => w.id === wsId)
|
||||
if (!ws || !ws.parent_workspace_id) return undefined
|
||||
return wsId
|
||||
})
|
||||
|
||||
async function handleConfirmedDelete() {
|
||||
const session = pendingDelete
|
||||
const forkToDelete = deleteAlsoFork ? pendingDeleteForkId : undefined
|
||||
// Capture the fork's parent before the workspace list is refreshed
|
||||
// below — afterwards the fork is gone from $userWorkspaces and the
|
||||
// lookup would return undefined.
|
||||
const forkParentId = forkToDelete
|
||||
? $userWorkspaces.find((w) => w.id === forkToDelete)?.parent_workspace_id
|
||||
: undefined
|
||||
pendingDelete = undefined
|
||||
deleteAlsoFork = true
|
||||
if (!session) return
|
||||
const wasActive = sessionState.currentSessionId === session.id
|
||||
removeSession(session.id)
|
||||
forgetSessionSeen(session.id)
|
||||
if (forkToDelete) {
|
||||
try {
|
||||
await WorkspaceService.deleteWorkspace({ workspace: forkToDelete })
|
||||
sendUserToast(`Deleted forked workspace ${forkToDelete}`)
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to delete fork ${forkToDelete}: ${e?.body ?? e}`, true)
|
||||
}
|
||||
}
|
||||
// If the deleted fork was the active workspace, fall back to its parent
|
||||
// so the user isn't stranded on a workspace that no longer exists.
|
||||
if (forkToDelete && forkParentId && $workspaceStore === forkToDelete) {
|
||||
syncWorkspaceTo(forkParentId)
|
||||
}
|
||||
if (wasActive) {
|
||||
const next = sessionState.sessions[0]
|
||||
if (next) await activate(next)
|
||||
else await goto('/sessions')
|
||||
}
|
||||
}
|
||||
|
||||
function focusAt(index: number) {
|
||||
const buttons = listRoot
|
||||
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
|
||||
: []
|
||||
if (buttons.length === 0) return
|
||||
const wrapped = ((index % buttons.length) + buttons.length) % buttons.length
|
||||
buttons[wrapped]?.focus()
|
||||
}
|
||||
|
||||
function handleListKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') {
|
||||
return
|
||||
}
|
||||
const buttons = listRoot
|
||||
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-session-button]'))
|
||||
: []
|
||||
if (buttons.length === 0) return
|
||||
const current = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||
e.preventDefault()
|
||||
if (e.key === 'ArrowDown') focusAt(current < 0 ? 0 : current + 1)
|
||||
else if (e.key === 'ArrowUp') focusAt(current < 0 ? buttons.length - 1 : current - 1)
|
||||
else if (e.key === 'Home') focusAt(0)
|
||||
else if (e.key === 'End') focusAt(buttons.length - 1)
|
||||
}
|
||||
|
||||
const menuItemBase = twMerge(
|
||||
'text-secondary text-left font-normal text-xs',
|
||||
'flex flex-row items-center gap-2 px-3 py-1.5 w-full',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !globalEnabled}
|
||||
<!-- Sessions hidden until the global-ai dev gate is enabled. -->
|
||||
{:else if isCollapsed}
|
||||
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<div class="relative">
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
icon={MessageSquare}
|
||||
label="AI sessions"
|
||||
{isCollapsed}
|
||||
{trigger}
|
||||
/>
|
||||
{#if totalUnread > 0}
|
||||
<span
|
||||
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
|
||||
aria-label="{totalUnread} unread message{totalUnread === 1
|
||||
? ''
|
||||
: 's'} across all sessions"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
<div class="divide-y min-w-48" role="none">
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem class={menuItemBase} onClick={createAndOpen} {item}>
|
||||
<Plus size={14} />
|
||||
New session
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div class="py-1" role="none">
|
||||
{#each visibleSessions as session (session.id)}
|
||||
{@const runtime = getRuntime(session.id)}
|
||||
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
|
||||
{@const isSelected =
|
||||
onSessionsPage && session.id === sessionState.currentSessionId}
|
||||
{@const unread = unreadFor(session)}
|
||||
{@const draft = hasDraft(session)}
|
||||
<MenuItem
|
||||
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
|
||||
onClick={() => activate(session)}
|
||||
{item}
|
||||
>
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
forkStatus={forkStatusFor(session)}
|
||||
/>
|
||||
<span
|
||||
class={twMerge(
|
||||
'truncate flex-1 text-left',
|
||||
unread > 0 ? 'font-semibold text-primary' : ''
|
||||
)}
|
||||
>
|
||||
{session.summary ?? 'Untitled session'}
|
||||
</span>
|
||||
{#if draft || unread > 0}
|
||||
<span class="ml-auto shrink-0 inline-flex items-center gap-1">
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-2 pt-3 pb-2 flex flex-col gap-1 border-b border-light dark:border-gray-700">
|
||||
<div class="flex flex-row items-center justify-between pl-1 pr-0.5">
|
||||
{#if visibleSessions.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (sectionCollapsed.val = !sectionCollapsed.val)}
|
||||
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
|
||||
aria-expanded={!sectionCollapsed.val}
|
||||
>
|
||||
AI sessions
|
||||
{#if sectionCollapsed.val}
|
||||
<ChevronRight size={10} />
|
||||
{:else}
|
||||
<ChevronDown size={10} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- No sessions yet: render the label as plain text (no collapse toggle). -->
|
||||
<span
|
||||
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 px-1 -mx-1 py-0.5"
|
||||
>
|
||||
AI sessions
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-0.5">
|
||||
<Popover placement="bottom-end" usePointerDownOutside disableFocusTrap class="inline-flex">
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
title="Filter sessions"
|
||||
aria-label="Filter sessions"
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary {showArchived.val
|
||||
? 'text-emphasis'
|
||||
: ''}"
|
||||
>
|
||||
<Filter size={12} />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div
|
||||
class="w-56 p-2 bg-surface-tertiary dark:border rounded-md shadow-lg flex flex-col gap-1"
|
||||
>
|
||||
<Toggle
|
||||
bind:checked={showArchived.val}
|
||||
size="xs"
|
||||
options={{ right: 'Show archived' }}
|
||||
/>
|
||||
{#if archivedCount > 0}
|
||||
<span class="text-2xs text-tertiary pl-1">
|
||||
{archivedCount} archived session{archivedCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs2"
|
||||
iconOnly
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={createAndOpen}
|
||||
title="New session"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !sectionCollapsed.val}
|
||||
<div
|
||||
bind:this={listRoot}
|
||||
transition:slide={{ duration: 180 }}
|
||||
class="flex flex-col gap-0.5 max-h-[40vh] overflow-y-auto"
|
||||
onkeydown={handleListKeydown}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#each visibleSessions as session (session.id)}
|
||||
{@const runtime = getRuntime(session.id)}
|
||||
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
|
||||
{@const isSelected = onSessionsPage && session.id === sessionState.currentSessionId}
|
||||
{@const isEditing = editingId === session.id}
|
||||
{@const unread = unreadFor(session)}
|
||||
{@const draft = hasDraft(session)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-row items-center group rounded',
|
||||
isSelected ? 'bg-surface-hover text-primary' : 'hover:bg-surface-hover',
|
||||
session.archived ? 'italic opacity-60' : ''
|
||||
)}
|
||||
>
|
||||
{#if isEditing}
|
||||
<span class="flex flex-row items-center gap-2 flex-1 px-2 py-1 min-w-0">
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
forkStatus={forkStatusFor(session)}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
else if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onblur={commitRename}
|
||||
placeholder="Untitled session"
|
||||
autofocus
|
||||
spellcheck="false"
|
||||
class="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs font-normal text-primary"
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
data-session-button
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onclick={() => activate(session)}
|
||||
class={twMerge(
|
||||
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
|
||||
unread > 0 ? 'text-primary font-semibold' : 'text-secondary'
|
||||
)}
|
||||
>
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={isForkFor(session)}
|
||||
forkStatus={forkStatusFor(session)}
|
||||
/>
|
||||
<span class="truncate flex-1">{session.summary ?? 'Untitled session'}</span>
|
||||
{#if draft || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity pr-0.5"
|
||||
>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-end"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => startRename(session)
|
||||
},
|
||||
session.archived
|
||||
? {
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
: {
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => setSessionArchived(session.id, true)
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (pendingDelete = session)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
open={!!pendingDelete}
|
||||
title="Delete session"
|
||||
confirmationText="Delete"
|
||||
onConfirmed={handleConfirmedDelete}
|
||||
onCanceled={() => {
|
||||
pendingDelete = undefined
|
||||
deleteAlsoFork = true
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p>
|
||||
Delete session <span class="font-medium text-primary"
|
||||
>{pendingDelete?.summary ?? pendingDelete?.name}</span
|
||||
>? This cannot be undone.
|
||||
</p>
|
||||
{#if pendingDeleteForkId}
|
||||
<div class="flex items-start gap-2 border rounded-md p-3 bg-surface-secondary">
|
||||
<Toggle size="xs" bind:checked={deleteAlsoFork} />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium text-primary"
|
||||
>Also delete forked workspace <span class="font-mono">{pendingDeleteForkId}</span></span
|
||||
>
|
||||
<span class="text-3xs text-tertiary"
|
||||
>The fork won't be reachable from any other session — leaving it would orphan it.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user