mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
* feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role <name>` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): data table roles in the DB manager and raw apps Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never add a role to the reference of a data table whose name contains '?' Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the roles of a data table whose name contains '?' The generated client leaves a '?' in a path param unencoded, so the lookup 404'd and the raw-app picker blocked Start on such a data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): refuse a reference naming both a legacy data table and a role When a workspace stores both `sales` and a legacy `sales?role=analytics`, the reference resolved to the legacy entry without a role, so browsing `sales` as `analytics` reached another data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: declare the default role in migrations written for a data table whose name contains '?' Such a data table connects as its default role without naming it, so the migrations the manager wrote for it declared no role and ran as admin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role <name>` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): drop a DuckDB data table secret once its ATTACH has used it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(datatables): resolve a workspace's data tables per pointer hop, not per entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): hold the parent's settings while a fork points at its data tables Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * chore: update ee-repo-ref to 7e338e4dabf91689bfd7fb0333c6534040b17b59 This commit updates the EE repository reference after PR #787 was merged in windmill-ee-private. Previous ee-repo-ref: 0edd40979cf36bfba59323f3f6a0811ae1369cf5 New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59 Automated by sync-ee-ref workflow. * feat(datatables): clone a data table under roles with its owners and grants (#11120) Claude-Session: https://claude.ai/code/session_01UbrtwiYNfayrmqouBJHwGV Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: open the raw app data table drawer when the workspace has none Selecting the first data table of an empty list passed undefined to the name check, which threw instead of opening the drawer on no data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): offer cloning a data table under roles where its grants can be replayed The server clones such a data table and replays the source's owners and grants, which only the Enterprise Edition does, so the fork wizard hid both clone options everywhere instead of on a build that cannot replay them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: name the placeholder the empty raw app data drawer renders Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the enterprise refusal the role pickers read as 'not under roles' The server's sentence and the frontend's copy of it were coupled by nothing, so rewording either one turned every role picker on a community build into a failed lookup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): read a roles answer only for the workspace it was asked in A fork and its parent each have their own roles on a data table of the same name, so an answer stamped with the name alone settled the role from the workspace the editor was acting on before. Also derive the AI table creation flag from the data replaced into the editor: data naming no data table left the flag on from before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep an instance database a settings save is waiting to name Cleanup for a database whose setup failed took the lock first, read no user, and dropped it while a save blocked on that same lock was about to commit a reference to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): check the workspace stamp in the default database selector too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): read what a save racing instance-database cleanup committed A transaction blocked on the lock may still roll back, so keeping the database for it stranded one whose name then blocks every retry: it is let through and its outcome read instead. The waiter query also matches this database's locks only, since pg_locks spans the cluster. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): tell a waiting request apart from the workspaces using a database Both callers render what cleanup returns as the workspaces that keep the database, so a waiting request's pid read as one of them. Each now words that case itself, and the give-up comment names where the kept name actually goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): take the cleanup lock on a connection the pool cannot reclaim A session lock outlives the future holding it, so a cancellation between taking it and releasing it handed a locked session back to the pool, where every later settings save waits on it. Detached, the connection closes when it is dropped and the server releases the lock with the session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): close the cleanup connection on drop instead of detaching it Detaching released the pool permit while the session stayed alive, so concurrent cleanups waiting on their locks could open as many connections as they liked. Closing on drop covers the same cancellation and keeps them counted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3 This commit updates the EE repository reference after PR #798 was merged in windmill-ee-private. Previous ee-repo-ref: 3145e422d61d580f0a82804f075285c112879da0 New ee-repo-ref: fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
4960 lines
206 KiB
TypeScript
4960 lines
206 KiB
TypeScript
import type { AttachedBlob } from './blobUtils'
|
|
import type { ChatViewHost } from './chatViewHost'
|
|
import type { ScriptLang } from '$lib/gen/types.gen'
|
|
import { JobService, type CompletedJob } from '$lib/gen'
|
|
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
|
|
import { getAiAgentProviderCatalog } from './flow/aiAgentProviderCatalog'
|
|
import { formatAiAgentProvidersPrompt } from './flow/aiAgentProviders'
|
|
import {
|
|
flowTools,
|
|
prepareFlowSystemMessage,
|
|
prepareFlowUserMessage,
|
|
type FlowAIChatHelpers
|
|
} from './flow/core'
|
|
import {
|
|
getAppTools,
|
|
prepareAppSystemMessage,
|
|
prepareAppUserMessage,
|
|
type AppAIChatHelpers
|
|
} from './app/core'
|
|
import ContextManager from './ContextManager.svelte'
|
|
import HistoryManager from './HistoryManager.svelte'
|
|
import {
|
|
type DisplayMessage,
|
|
type Tool,
|
|
type ToolCallbacks,
|
|
type ToolDisplayMessage,
|
|
type UserQuestionDisplay,
|
|
type RunFormDisplay,
|
|
type RunFormDraft,
|
|
type ChatJob,
|
|
type ChatJobInit,
|
|
type ChatJobStatus,
|
|
completedJobToolStatus,
|
|
backgroundJobCompletionNote,
|
|
createJobUpdateReader,
|
|
deriveChatJobStatus,
|
|
pendingToolImagesMessage,
|
|
trimJob
|
|
} from './shared'
|
|
import type {
|
|
ChatCompletionMessageParam,
|
|
ChatCompletionSystemMessageParam
|
|
} from 'openai/resources/chat/completions.mjs'
|
|
import {
|
|
prepareInlineChatSystemPrompt,
|
|
prepareScriptSystemMessage,
|
|
prepareScriptTools
|
|
} from './script/core'
|
|
import type { ScriptLintResult } from './shared'
|
|
import { navigatorTools, prepareNavigatorSystemMessage } from './navigator/core'
|
|
import { loadApiTools } from './api/apiTools'
|
|
import { prepareScriptUserMessage } from './script/core'
|
|
import { prepareNavigatorUserMessage } from './navigator/core'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { workspaceAIClients, getNonStreamingCompletion } from '../lib'
|
|
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
|
import { modelSupportsVision } from '../modelConfig'
|
|
import { getModelContextWindow } from '../modelConfig'
|
|
import {
|
|
getCompactionSummaryPrompt,
|
|
formatCompactSummary,
|
|
buildSummaryMessageContent
|
|
} from './compactionPrompt'
|
|
import { dfs } from '$lib/components/flows/previousResults'
|
|
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
|
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
|
import { createLongHash } from '$lib/editorLangUtils'
|
|
import type { AIProvider, UserDraftItemKind } from '$lib/gen'
|
|
import { maskKey } from '$lib/components/sessions/modifiedItemsMask'
|
|
import { getStringError } from './utils'
|
|
import { type PasteAttachment } from './pasteTokens'
|
|
import {
|
|
type AttachedImage,
|
|
imagesFromContent,
|
|
MAX_ATTACHED_IMAGES,
|
|
messagesHaveImageParts,
|
|
stripImagePartsFromMessages
|
|
} from './imageUtils'
|
|
import { chatDraft, expanded } from './chatDraft'
|
|
import { MessageDraft, type DraftSnapshot } from './messageDraft.svelte'
|
|
import {
|
|
MAX_ATTACHED_FILES,
|
|
sanitizeAttachmentName,
|
|
textByteLength,
|
|
withAttachedTextFileIds,
|
|
type AttachedTextFile
|
|
} from './textFileUtils'
|
|
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
|
|
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
|
|
import { untrack } from 'svelte'
|
|
import { get } from 'svelte/store'
|
|
import { BROWSER } from 'esm-env'
|
|
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
|
import { copilotInfo } from '$lib/aiStore'
|
|
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
|
|
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
|
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
|
import { prefersInstantReveal, TypewriterReveal } from './typewriterReveal'
|
|
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
|
import {
|
|
createAppBackendRunnableContextElement,
|
|
createAppFrontendFileContextElement,
|
|
flattenDatatablesToAppContextElements,
|
|
isMentionContext,
|
|
isSameContextElement,
|
|
type ContextElement,
|
|
type AppDatatableElement
|
|
} from './context'
|
|
import type { Selection } from 'monaco-editor'
|
|
import type AIChatInput from './AIChatInput.svelte'
|
|
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
|
|
import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
|
|
import { sanitizeToolCallArguments } from './toolCallArguments'
|
|
import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage'
|
|
import { logAiUsage } from '$lib/utils/aiUsageReporter'
|
|
import type { ReviewChangesOpts } from './monaco-adapter'
|
|
import {
|
|
getCurrentModel,
|
|
tryGetCurrentModel,
|
|
getCombinedCustomPrompt,
|
|
getCustomPromptParts,
|
|
getUserCustomPrompts,
|
|
setUserCustomPrompts,
|
|
isWebSearchEnabledForProvider
|
|
} from '$lib/aiStore'
|
|
import type { WorkspaceMutationTarget } from './workspaceTools'
|
|
import {
|
|
globalToolsFor,
|
|
loadWorkspaceSkills,
|
|
prepareGlobalSystemMessage,
|
|
resolveGlobalPromptIdentity,
|
|
type GlobalPromptIdentity,
|
|
prepareGlobalUserMessage,
|
|
type AiSkillListItem,
|
|
type ChatCommandItem,
|
|
type SessionPromptContext,
|
|
getSessionContextPromptSection,
|
|
type GlobalToolHelpers,
|
|
type GlobalActivePreviewContext
|
|
} from './global/core'
|
|
import { formatChatJobCompletion } from './datatableTools'
|
|
import { isGlobalAiEnabled } from './global/gate'
|
|
import { createMcpTools, loadMcpServers, type McpServer } from './global/mcpTools'
|
|
import {
|
|
pipelineTools,
|
|
getPipelinePromptSection,
|
|
type PipelineAIChatHelpers
|
|
} from './pipeline/core'
|
|
import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage'
|
|
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
|
import { AttachedFilesStore } from './files/attachedFiles.svelte'
|
|
import { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
|
|
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
|
import { appendAttachedFilesRoster } from './files/fileTools'
|
|
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
|
|
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
|
|
|
|
// Compaction of the stored history: once the projected request size
|
|
// (contextTokens — the provider's report when current, a fresh chars/4
|
|
// estimate otherwise — plus the new user message) reaches the trigger ratio of
|
|
// the model's context window, the older prefix is summarized into a single
|
|
// message while the recent tail is kept verbatim, bringing the history down to
|
|
// roughly the target ratio. The trigger headroom absorbs what the projection
|
|
// cannot see — the upcoming completion and tool results, system-prompt/tool-
|
|
// schema changes from mode switches, and the estimate's chars/4 error.
|
|
const COMPACTION_TRIGGER_RATIO = 0.8
|
|
const COMPACTION_TARGET_RATIO = 0.7
|
|
// How often a running turn is offered to the mid-turn checkpoint (see
|
|
// sendRequest). The whole transcript is rewritten on each accepted checkpoint,
|
|
// so this bounds the write rate; it also bounds how much of a turn a tab that
|
|
// dies without warning can lose.
|
|
const CHECKPOINT_INTERVAL_MS = 2000
|
|
// Stands in for the result of a tool call that had not finished when the
|
|
// transcript was checkpointed — still running, or still waiting to be confirmed.
|
|
// The model reads the step as unfinished, which is what the card tells the reader.
|
|
const INTERRUPTED_TOOL_RESULT = 'Interrupted: the chat was closed before this tool finished'
|
|
// Flat per-image token estimate for a downscaled (≤1568px) vision image. Used instead
|
|
// of chars/4 on the base64 data URL, which would overcount by ~50x.
|
|
const IMAGE_TOKEN_ESTIMATE = 1200
|
|
// Headroom reserved within the target budget for the summary message itself, so
|
|
// the summary + kept tail + overhead land under the target ratio.
|
|
const SUMMARY_OUTPUT_RESERVE_TOKENS = 8000
|
|
// Below this many messages in the prefix there's little to gain from a summary
|
|
// round-trip; skip straight to drop-oldest.
|
|
const MIN_PREFIX_MESSAGES_TO_SUMMARIZE = 4
|
|
// Stop attempting summarization after this many consecutive failures and use
|
|
// drop-oldest directly; a successful summarization resets the counter.
|
|
const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
|
|
// Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels
|
|
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
|
|
// flush can tell "the user wants to move on" from "the turn was torn down".
|
|
const USER_CANCEL_REASON = 'user_cancelled'
|
|
// Applied wherever a run form stops rendering. Only the form reads the deployed schema,
|
|
// so past that point it is a copy of the script's declarations — password and file
|
|
// defaults with them — persisted for the life of the chat.
|
|
const settledRunForm = (runForm: RunFormDisplay): RunFormDisplay =>
|
|
runForm.submitted || runForm.canceled
|
|
? { ...runForm, schema: undefined, code: undefined, lang: undefined }
|
|
: runForm
|
|
|
|
/** A run form the chat is holding open, keyed by tool call id. */
|
|
type PendingRunForm = {
|
|
/** Absent once the loop is no longer waiting: a card restored from history still mounts
|
|
* its form and still holds edits, but nothing is left to receive them. */
|
|
resolve?: (args: Record<string, any> | undefined) => void
|
|
draft: RunFormDraft
|
|
submitting: boolean
|
|
}
|
|
// Built-in `/compact` session command — summarizes the conversation locally
|
|
// instead of sending a turn to the model. Matched on the whole input so a
|
|
// regular message that merely mentions "/compact" mid-sentence is unaffected.
|
|
const COMPACT_COMMAND_NAME = 'compact'
|
|
const COMPACT_COMMAND_RE = /^\/compact\s*$/
|
|
// Built-in `/clear` session command — saves the conversation to history and
|
|
// resets to a fresh chat (the "New chat" action), instead of sending a turn.
|
|
const CLEAR_COMMAND_NAME = 'clear'
|
|
const CLEAR_COMMAND_RE = /^\/clear\s*$/
|
|
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
|
|
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
|
|
const WEB_SEARCH_ERROR_HINT =
|
|
'Web search is unavailable for this provider/model/key. Disable web search in workspace settings and try again.'
|
|
// The full explanation is shown once per browser; afterwards the hidden
|
|
// thinking is only hinted at discreetly in the typing indicator.
|
|
const REASONING_SUMMARY_WARNED_STORAGE_KEY = 'ai-chat-reasoning-summary-unverified-warned'
|
|
|
|
function providerDisplayName(provider: string): string {
|
|
return provider === 'azure_openai' ? 'Azure OpenAI' : 'OpenAI'
|
|
}
|
|
|
|
function reasoningSummaryUnavailableMessage(provider: string): string {
|
|
const verifyHint =
|
|
provider === 'azure_openai'
|
|
? 'To display it, verify your organization with your provider, then reload this page.'
|
|
: 'To display it, verify your organization in the OpenAI platform settings (Settings > General), then reload this page.'
|
|
return `This model is reasoning, but your ${providerDisplayName(provider)} organization is not verified to generate reasoning summaries, so its thinking stays hidden. ${verifyHint}`
|
|
}
|
|
|
|
export enum AIMode {
|
|
SCRIPT = 'script',
|
|
FLOW = 'flow',
|
|
APP = 'app',
|
|
NAVIGATOR = 'navigator',
|
|
API = 'API',
|
|
GLOBAL = 'global',
|
|
ASK = 'ask'
|
|
}
|
|
|
|
export enum AIAutonomyMode {
|
|
PLAN = 'plan',
|
|
DEFAULT = 'default',
|
|
ACCEPT_EDIT = 'acceptedit',
|
|
YOLO = 'yolo'
|
|
}
|
|
|
|
const ALL_AI_MODES = Object.values(AIMode)
|
|
const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode)
|
|
const AUTO_ACCEPT_EDIT_MODES = new Set<AIMode>([AIMode.SCRIPT, AIMode.FLOW])
|
|
const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set<AIMode>([
|
|
AIMode.SCRIPT,
|
|
AIMode.FLOW,
|
|
AIMode.APP,
|
|
AIMode.GLOBAL
|
|
])
|
|
const PLAN_MODES = new Set<AIMode>([AIMode.GLOBAL])
|
|
|
|
export function isAIMode(mode: unknown): mode is AIMode {
|
|
return ALL_AI_MODES.includes(mode as AIMode)
|
|
}
|
|
|
|
export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode {
|
|
return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode)
|
|
}
|
|
|
|
export function supportsAutoAcceptEdits(mode: AIMode): boolean {
|
|
return AUTO_ACCEPT_EDIT_MODES.has(mode)
|
|
}
|
|
|
|
export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean {
|
|
return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode)
|
|
}
|
|
|
|
export function supportsPlanMode(mode: AIMode): boolean {
|
|
return PLAN_MODES.has(mode)
|
|
}
|
|
|
|
export function isAIModeVisible(mode: AIMode): boolean {
|
|
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
|
|
}
|
|
|
|
export function getVisibleAIModes(): AIMode[] {
|
|
return ALL_AI_MODES.filter(isAIModeVisible)
|
|
}
|
|
|
|
function isWorkspacePath(path: string | undefined): path is string {
|
|
return path?.startsWith('f/') === true || path?.startsWith('u/') === true
|
|
}
|
|
|
|
// The autonomy mode is namespaced by the logged-in user's email (scopedKey).
|
|
// It controls whether tool calls auto-execute, so leaking it across users on a
|
|
// shared browser is a safety concern (user B inheriting user A's YOLO mode).
|
|
// Returns the safe ACCEPT_EDIT default when no user is known yet — the
|
|
// module-level singleton (constructed at import, before the email resolves)
|
|
// re-reads via onUserChange once it does.
|
|
function getPersistedAutonomyMode(): AIAutonomyMode {
|
|
const key = scopedKey(AI_AUTONOMY_MODE_STORAGE_KEY)
|
|
if (!BROWSER || !key) {
|
|
return AIAutonomyMode.ACCEPT_EDIT
|
|
}
|
|
const persistedMode = getLocalSetting(key)
|
|
if (isAIAutonomyMode(persistedMode) && persistedMode !== AIAutonomyMode.PLAN) {
|
|
return persistedMode
|
|
}
|
|
// No stored preference: default to auto-accepting edits (tool calls still
|
|
// require confirmation; only YOLO bypasses those). Note this means users who
|
|
// never opened the autonomy picker now start with edit auto-accept on.
|
|
const legacyKey = scopedKey(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY)
|
|
return legacyKey && getLocalSetting(legacyKey) === 'true'
|
|
? AIAutonomyMode.YOLO
|
|
: AIAutonomyMode.ACCEPT_EDIT
|
|
}
|
|
|
|
function persistAutonomyMode(mode: AIAutonomyMode) {
|
|
// Plan is session-only: persisting it would re-block a later session where the
|
|
// picker never offered Plan. The stored pre-plan baseline is what a reload restores.
|
|
if (mode === AIAutonomyMode.PLAN) {
|
|
return
|
|
}
|
|
const key = scopedKey(AI_AUTONOMY_MODE_STORAGE_KEY)
|
|
if (!BROWSER || !key) {
|
|
return
|
|
}
|
|
storeLocalSetting(key, mode)
|
|
}
|
|
|
|
// Claim the pre-namespacing autonomy keys for the first user to log in on a
|
|
// previously single-user browser.
|
|
function migrateLegacyAutonomyKeys() {
|
|
migrateLegacyLocalStorage(AI_AUTONOMY_MODE_STORAGE_KEY, scopedKey(AI_AUTONOMY_MODE_STORAGE_KEY))
|
|
migrateLegacyLocalStorage(
|
|
LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY,
|
|
scopedKey(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY)
|
|
)
|
|
}
|
|
|
|
function appendWebSearchErrorHint(message: string, shouldAppend: boolean): string {
|
|
if (!shouldAppend) {
|
|
return message
|
|
}
|
|
const separator = /[.!?]$/.test(message.trim()) ? ' ' : '. '
|
|
return `${message}${separator}${WEB_SEARCH_ERROR_HINT}`
|
|
}
|
|
|
|
/**
|
|
* Whether a provider rejected the request over an image it could not take. The
|
|
* vision gate only knows the models we ship, so this is the net for the rest:
|
|
* every provider words it differently, hence matching on the subject rather than
|
|
* a code. Only consulted when the outbound request actually carried an image, so
|
|
* an unrelated error mentioning "image" cannot trigger it on its own.
|
|
*/
|
|
function isImageRejection(err: unknown, models: (string | undefined)[] = []): boolean {
|
|
let message = (err instanceof Error ? err.message : String(err)).toLowerCase()
|
|
// Vision-capable model ids often contain the subject words themselves
|
|
// (llama-3.2-90b-vision-instruct, Phi-4-multimodal-instruct) and providers echo
|
|
// the id in unrelated errors (rate limits, capacity). A match inside the id
|
|
// would treat those as rejections and destroy good images, so drop the ids
|
|
// before matching — only the error's own wording counts. Callers pass every
|
|
// model the turn may have used: the error can come from the model selected at
|
|
// send time OR the one currently selected (switchable mid-flight).
|
|
for (const model of models) {
|
|
if (model) message = message.replaceAll(model.toLowerCase(), '')
|
|
}
|
|
// Whole words only: "provisioning"/"provisioned" contain "vision", and a
|
|
// transient capacity error must not destroy good images. image_url and
|
|
// input_image are the content-part names providers echo in schema errors
|
|
// ('_' is a word char, so \bimage\b alone would miss them).
|
|
return /\bimages?(_url)?\b|\binput_image\b|\bvision\b|\bmultimodal\b/.test(message)
|
|
}
|
|
|
|
function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean): string {
|
|
const errorMessage =
|
|
err instanceof Error ? err.message : typeof err === 'string' ? err : undefined
|
|
const message = errorMessage
|
|
? `Failed to send request: ${errorMessage}`
|
|
: 'Failed to send request'
|
|
return appendWebSearchErrorHint(message, webSearchUnavailable)
|
|
}
|
|
|
|
/** Re-fetch copilotInfo after a free-tier turn so the usage banner tracks spend live and the
|
|
* exhausting turn flips `freeTier.exhausted`; otherwise these update only on the next workspace
|
|
* load. Scoped to a live (non-exhausted) free tier so configured-key users pay no extra request. */
|
|
async function refreshFreeTierUsage(workspace: string | undefined) {
|
|
if (!workspace) return
|
|
// copilotInfo is a singleton shared across sessions: a warm session finishing after a
|
|
// workspace switch must not loadCopilot for its now-background workspace. Gate on the
|
|
// most-recently-*requested* workspace (set synchronously) so a refresh can't win the
|
|
// monotonic token over a newer load still in flight.
|
|
if (get(copilotWorkspaceRequested) !== workspace) return
|
|
const info = get(copilotInfo)
|
|
if (!info.freeTier || info.freeTier.exhausted) return
|
|
try {
|
|
await loadCopilot(workspace)
|
|
} catch (err) {
|
|
console.error('Failed to refresh free-tier usage', err)
|
|
}
|
|
}
|
|
|
|
/** A message queued while a turn streams: the draft lanes and the pinned
|
|
* context snapshot always move together so a flush can't drop one. */
|
|
type QueuedEntry = {
|
|
draft: DraftSnapshot
|
|
context: ContextElement[] | undefined
|
|
}
|
|
|
|
/** Plan mode's view of the chat it runs in. A function rather than an object literal in the
|
|
* field initializer so the getters close over the manager instead of over themselves. */
|
|
function planModeHostFor(m: AIChatManager): PlanModeHost {
|
|
return {
|
|
get active() {
|
|
return m.planModeActive
|
|
},
|
|
get available() {
|
|
return m.planModeAvailable
|
|
},
|
|
get autoAccepting() {
|
|
return m.autoAcceptToolConfirmationsActive
|
|
},
|
|
get isSessionChat() {
|
|
return m.isSessionChat
|
|
},
|
|
get sessionId() {
|
|
return m.sessionId
|
|
},
|
|
get chatId() {
|
|
return m.historyManager.getCurrentChatId()
|
|
},
|
|
get artifacts() {
|
|
return m.artifacts
|
|
},
|
|
openArtifact: (id, name, version) => m.openArtifact?.(id, name, version),
|
|
enter: () => m.setAutonomyMode(AIAutonomyMode.PLAN),
|
|
restore: () => m.setAutonomyMode(m.prePlanAutonomyMode ?? AIAutonomyMode.DEFAULT)
|
|
}
|
|
}
|
|
|
|
export class AIChatManager implements ChatViewHost {
|
|
contextManager = new ContextManager()
|
|
historyManager = new HistoryManager()
|
|
// The copilot owns its model choice and its own transcript, so both chat
|
|
// affordances apply here. See ChatViewHost for hosts where they don't.
|
|
supportsModelSettings = true
|
|
supportsMessageEditing = true
|
|
// The copilot turn is the attachments themselves when there is no text.
|
|
requiresMessageText = false
|
|
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
|
|
// getters because `mode` changes under a mounted composer.
|
|
get supportsMessageAttachments() {
|
|
return this.mode === AIMode.GLOBAL
|
|
}
|
|
get supportsLinkedFolders() {
|
|
return this.mode === AIMode.GLOBAL
|
|
}
|
|
// The copilot reads attachments in the browser, so non-image files decode to text.
|
|
attachmentsAsBlobs = false
|
|
// The copilot decodes its attachments, so nothing ever lands in the blob lane.
|
|
queuedBlobs: AttachedBlob[] = []
|
|
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
|
|
// the message — text files after a content sniff).
|
|
attachmentAccept =
|
|
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
|
/** Files the user attached to the current GLOBAL-mode conversation. */
|
|
attachedFiles = new AttachedFilesStore()
|
|
/** Markdown artifacts the copilot created for the current session. */
|
|
artifacts = new SessionArtifactsStore()
|
|
abortController: AbortController | undefined = undefined
|
|
inlineAbortController: AbortController | undefined = undefined
|
|
// Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it)
|
|
skipResponsesApi = false
|
|
|
|
mode = $state<AIMode>(AIMode.NAVIGATOR)
|
|
pipelineAiChatHelpers = $state<PipelineAIChatHelpers | undefined>(undefined)
|
|
// Resolved when a pipeline editor registers its tools. open_preview(pipeline)
|
|
// awaits this so the model's next build_pipeline_node call can't race ahead of
|
|
// the async canvas mount and hit "Unknown tool call".
|
|
#pipelineHelpersWaiters = new Set<() => void>()
|
|
readonly isOpen = $derived(chatState.size > 0)
|
|
savedSize = $state<number>(0)
|
|
instructions = $state<string>('')
|
|
pendingPrompt = $state<string>('')
|
|
// Message queued while a turn is streaming. There is only ever one queued
|
|
// draft; pressing Enter again appends another line to it. Auto-sent when
|
|
// the turn finishes (clean completion or user cancel). Ephemeral — never
|
|
// saved to displayMessages or history. Owning it as a MessageDraft means
|
|
// every aggregation applies the draft rules (fold, caps, lanes move
|
|
// together) instead of re-implementing them here.
|
|
#queuedDraft = new MessageDraft()
|
|
// Context snapshot to send WITH the queued message, when it must stay scoped to
|
|
// what was selected at queue time (e.g. an inline element prompt submitted mid-
|
|
// stream) rather than the live selection, which may change before the flush.
|
|
queuedContext = $state<ContextElement[] | undefined>(undefined)
|
|
get queuedMessage(): string {
|
|
return this.#queuedDraft.text
|
|
}
|
|
set queuedMessage(text: string) {
|
|
this.#queuedDraft.text = text
|
|
}
|
|
get queuedImages(): AttachedImage[] {
|
|
return this.#queuedDraft.images
|
|
}
|
|
get queuedFiles(): AttachedTextFile[] {
|
|
return this.#queuedDraft.files
|
|
}
|
|
// Jobs the chat started that detached into the background (global/sessions
|
|
// chat only). Rendered in the jobs tray, persisted with the chat, and advanced
|
|
// by a single background poller. See registerJob / #pollBackgroundJobs.
|
|
backgroundJobs = $state<ChatJob[]>([])
|
|
// Completion notes for finished background jobs awaiting delivery to the model.
|
|
// Drained as a preamble into the next turn — either the user's next message, or,
|
|
// when the chat is idle, an auto-resume turn started for them (see
|
|
// #maybeAutoResumeFromJobs). Ephemeral like queuedMessage — not persisted.
|
|
pendingJobNotes = $state<string[]>([])
|
|
// Guards #maybeAutoResumeFromJobs against re-entering while its own turn spins up.
|
|
#autoResuming = false
|
|
#jobPollTimer: ReturnType<typeof setTimeout> | undefined = undefined
|
|
#jobPollDelay = 2000
|
|
// True while a #pollBackgroundJobs pass is executing. #stopJobPoller only clears
|
|
// the scheduled timer, not an in-flight poll, so without this a refreshBackgroundJobs
|
|
// mid-poll (cancel / approval close) would start a second concurrent poll chain and
|
|
// double the poll rate. The guard makes such a refresh coalesce into the running pass.
|
|
#isPolling = false
|
|
// Bumped on every conversation switch (clearBackgroundJobs). An in-flight poll
|
|
// captures it before its awaits and bails if it changed, so a getJob that
|
|
// resolves after the user switched chats can't mutate the newly-loaded one.
|
|
#jobPollGeneration = 0
|
|
// Consecutive getJob failures per background job, so a vanished/404 job can be
|
|
// drained instead of polled forever. Ephemeral, keyed by jobId.
|
|
#jobPollFailures = new Map<string, number>()
|
|
// Incremental log/result-stream readers, keyed by jobId. A job that detaches out of
|
|
// the inline wait keeps streaming into its card through these; each holds its own
|
|
// offsets, so one created after a reload refetches from the start.
|
|
#jobUpdateReaders = new Map<string, ReturnType<typeof createJobUpdateReader>>()
|
|
/** Opens a run in the sessions preview pane. Set by the session runtime;
|
|
* undefined in the global side-panel chat, where the tray falls back to opening
|
|
* the run in a new browser tab. */
|
|
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
|
|
/** Opens a pending run form in the sessions preview pane, on the same tool call the
|
|
* chat card holds. Unset outside a session: a chat-bound form has nowhere else to go,
|
|
* so the card hides the control rather than offering a tab that cannot run. */
|
|
openRunForm?: (a: { toolCallId: string; label: string }) => void
|
|
closeRunForm?: (toolCallId: string) => void
|
|
/** Hands that tab from the form to the run it just started, in place: the tab keeps its
|
|
* position in the strip and stays active if it was. */
|
|
showRunInPlaceOfForm?: (a: {
|
|
toolCallId: string
|
|
jobId: string
|
|
workspace: string
|
|
label: string
|
|
}) => void
|
|
/** Whether the panel holds this call's pending form. Answered off the session's tab list,
|
|
* so it stays true while the user is on another tab, and per call rather than "the open
|
|
* one". Read from a `$derived` — the reader subscribes to the tab list through the call.
|
|
* The card hides its form on it, which is what keeps exactly one mounted per call. */
|
|
isRunFormInPreview?: (toolCallId: string) => boolean
|
|
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
|
closeArtifact?: (artifactId: string) => void
|
|
#loading = $state<boolean>(false)
|
|
get loading(): boolean {
|
|
return this.#loading
|
|
}
|
|
// An accessor so every run bracket — the send turn, manual compaction, a
|
|
// rollback — reports its transitions through one place, synchronously: the
|
|
// rising edge posts the cross-tab "running here" signal the moment the
|
|
// bracket opens (after the send's preflight awaits; the post-preflight
|
|
// guard covers that gap), and `loading` falls only after the turn's last
|
|
// saveChat, making the falling edge the "safe to re-read the record" signal.
|
|
set loading(v: boolean) {
|
|
if (v === this.#loading) return
|
|
this.#loading = v
|
|
this.onRunningChanged?.(v)
|
|
}
|
|
/** Sessions wiring (see sessionRuntime); undefined for the global
|
|
* side-panel chat, whose transcript no other tab renders. */
|
|
onRunningChanged: ((running: boolean) => void) | undefined = undefined
|
|
currentReply = $state<string>('')
|
|
currentReasoning = $state<string>('')
|
|
currentReasoningActive = $state<boolean>(false)
|
|
// The provider reasons but refuses to stream summaries (unverified OpenAI
|
|
// organization) — drives the discreet "Thinking (hidden)" indicator. Keyed
|
|
// by workspace:provider like the chat-loop fallback cache, so the hint never
|
|
// carries over to a provider or workspace whose summaries work. A list, not
|
|
// a scalar: several workspace/provider pairs can be unavailable at once, and
|
|
// the chat loop only notifies on first detection per pair.
|
|
private reasoningSummaryUnavailableFor = $state<string[]>([])
|
|
// Timed off arrival, not off the typewriter: the reveal paces *display*, so
|
|
// reading the clock there would report how long the text took to paint.
|
|
private reasoningStartedAt: number | undefined
|
|
private reasoningEndedAt: number | undefined
|
|
/** Set the moment thinking ends, which is mid-turn — the answer is still
|
|
* streaming. Reactive so the live message settles to "Thought for X" then,
|
|
* rather than waiting for the turn to finalize. */
|
|
currentReasoningDurationMs = $state<number | undefined>(undefined)
|
|
|
|
private markReasoningStarted() {
|
|
if (this.reasoningStartedAt === undefined) {
|
|
this.reasoningStartedAt = Date.now()
|
|
this.currentReasoningDurationMs = undefined
|
|
}
|
|
}
|
|
|
|
/** Thinking ends at the first answer token; a turn that thinks straight into a
|
|
* tool call ends it at the message boundary instead. */
|
|
private markReasoningEnded() {
|
|
if (this.reasoningStartedAt !== undefined && this.reasoningEndedAt === undefined) {
|
|
this.reasoningEndedAt = Date.now()
|
|
this.currentReasoningDurationMs = this.reasoningEndedAt - this.reasoningStartedAt
|
|
}
|
|
}
|
|
|
|
private resetReasoningTiming() {
|
|
this.reasoningStartedAt = undefined
|
|
this.reasoningEndedAt = undefined
|
|
this.currentReasoningDurationMs = undefined
|
|
}
|
|
|
|
/** Reads the duration and clears it, so the next reasoning pass of the same
|
|
* turn (after a tool call) times itself from scratch. */
|
|
private takeReasoningDuration(): number | undefined {
|
|
const duration = this.currentReasoningDurationMs
|
|
this.resetReasoningTiming()
|
|
return duration
|
|
}
|
|
|
|
private reasoningSummaryKey(provider: string): string {
|
|
return `${this.operatingWorkspace ?? ''}:${provider}`
|
|
}
|
|
|
|
/** Label for the live "Thinking" indicator when thinking stays hidden for
|
|
* the current workspace/provider, undefined otherwise. */
|
|
get reasoningHiddenIndicatorLabel(): string | undefined {
|
|
if (this.reasoningSummaryUnavailableFor.length === 0) {
|
|
return undefined
|
|
}
|
|
const provider = getCurrentModel().provider
|
|
if (!this.reasoningSummaryUnavailableFor.includes(this.reasoningSummaryKey(provider))) {
|
|
return undefined
|
|
}
|
|
return `Thinking (hidden, ${providerDisplayName(provider)} org not verified)`
|
|
}
|
|
// Smooths the provider's bursty delivery into continuous typing by revealing
|
|
// buffered text a slice per frame. The reply and the reasoning/thinking stream
|
|
// each get their own reveal (independent buffers, both append to their own
|
|
// $state). Reduced-motion (sampled once — the pref never changes mid-session)
|
|
// and SSR fall back to instant.
|
|
private replyReveal = new TypewriterReveal({
|
|
onReveal: (chunk) => (this.currentReply += chunk),
|
|
instant: prefersInstantReveal()
|
|
})
|
|
private reasoningReveal = new TypewriterReveal({
|
|
onReveal: (chunk) => (this.currentReasoning += chunk),
|
|
instant: prefersInstantReveal()
|
|
})
|
|
displayMessages = $state<DisplayMessage[]>([])
|
|
messages = $state<ChatCompletionMessageParam[]>([])
|
|
/** Images buffered by tools (e.g. take_screenshot) during the current tool batch,
|
|
* keyed by toolId. Drained by appendPendingToolImages into a follow-up user message
|
|
* after the batch. Cleared at each turn start so an aborted batch can't leak. */
|
|
private pendingToolImages = new Map<string, AttachedImage[]>()
|
|
/** Model of the most recent loop iteration, recorded via onBeforeIteration.
|
|
* The selector stays switchable mid-flight, so when a request fails neither
|
|
* the send-time nor the currently-selected model necessarily names the one
|
|
* whose request is being classified (A→B→C switches). Reset at each turn
|
|
* start, consumed by image-rejection recovery. */
|
|
private lastIterationModel: ReturnType<typeof getCurrentModel> | undefined = undefined
|
|
/** Provider-reported context size of the last committed turn (prompt +
|
|
* completion of its latest completion — exact, includes system prompt and
|
|
* tools), or undefined whenever no report describes the current history
|
|
* (provider never reported, turn failed, history rewound). Never holds a
|
|
* guess: readers go through `contextTokens`, which estimates lazily. */
|
|
contextUsage = $state<number | undefined>(undefined)
|
|
// Circuit breaker for summary-based compaction: after repeated failures the
|
|
// summary round-trip is skipped in favor of drop-oldest. Reset on any
|
|
// successful summarization. Not persisted — a fresh load gets a fresh chance.
|
|
private consecutiveCompactionFailures = 0
|
|
// True while the summarization round-trip is in flight, so the UI can show a
|
|
// "Compacting conversation" label on the processing indicator.
|
|
compacting = $state(false)
|
|
// General-purpose label for the processing indicator, set by a beforeSend hook
|
|
// to describe pre-flight work (e.g. "Creating workspace fork...") that runs
|
|
// before the request goes out. Takes precedence over the compacting/thinking
|
|
// labels while set; the hook clears it back to undefined when done.
|
|
loadingLabel = $state<string | undefined>(undefined)
|
|
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
|
|
// Set by AI sessions. Enables the session-only preview tools and gates plan mode, which
|
|
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
|
|
// `planModeAvailable` derives from it.
|
|
isSessionChat = $state(false)
|
|
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
|
|
autoAcceptEditsActive = $derived(
|
|
this.autoAcceptEditsAvailable &&
|
|
(this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT ||
|
|
this.autonomyMode === AIAutonomyMode.YOLO)
|
|
)
|
|
autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode))
|
|
autoAcceptToolConfirmationsActive = $derived(
|
|
this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable
|
|
)
|
|
planModeAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode))
|
|
planModeActive = $derived(this.autonomyMode === AIAutonomyMode.PLAN && this.planModeAvailable)
|
|
prePlanAutonomyMode = $state<AIAutonomyMode | undefined>(undefined)
|
|
// The posture's own state — its two tools, the plan document and the planning round.
|
|
// Everything it needs from this manager goes through the host above.
|
|
planMode = new PlanModeController(planModeHostFor(this))
|
|
#automaticScroll = $state<boolean>(true)
|
|
systemMessage = $state<ChatCompletionSystemMessageParam>({
|
|
role: 'system',
|
|
content: ''
|
|
})
|
|
tools = $state<Tool<any>[]>([])
|
|
helpers = $state<any | undefined>(undefined)
|
|
|
|
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
|
|
flowOptions = $state<FlowOptions | undefined>(undefined)
|
|
scriptEditorApplyCode = $state<
|
|
((code: string, opts?: ReviewChangesOpts) => void | Promise<void>) | undefined
|
|
>(undefined)
|
|
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
|
|
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
|
|
/** The editor a FLOW-mode chat belongs to: the page owning the chat names itself here, and a
|
|
* nested editor (a subflow drawer) takes it over while it is open. Unset in a session chat,
|
|
* which keeps every open editor tab mounted and could only name an arbitrary one — a session
|
|
* resolves an editor by its storage path through `flowEditorFor`. */
|
|
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
|
|
/** Every mounted flow editor. */
|
|
#flowEditors = new Set<FlowAIChatHelpers>()
|
|
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
|
|
/** Datatable creation policy: enabled flag, datatable name, optional schema, and the role the
|
|
* app uses each data table through */
|
|
datatableCreationPolicy = $state<{
|
|
enabled: boolean
|
|
datatable: string | undefined
|
|
schema: string | undefined
|
|
roles?: Record<string, string>
|
|
}>({ enabled: false, datatable: undefined, schema: undefined, roles: undefined })
|
|
pendingNewCode = $state<string | undefined>(undefined)
|
|
apiTools = $state<Tool<any>[]>([])
|
|
aiChatInput = $state<AIChatInput | null>(null)
|
|
/** Cached datatables for app context (fetched asynchronously) */
|
|
cachedDatatables = $state<AppDatatableElement[]>([])
|
|
|
|
private confirmationCallbacks = new Map<
|
|
string,
|
|
{ resolve: (value: boolean) => void; toolName?: string }
|
|
>()
|
|
private userQuestionCallbacks = new Map<string, (choices: string[] | undefined) => void>()
|
|
/**
|
|
* One run form's whole life while it waits, so ending it is one delete and cannot end half
|
|
* of it. Held here rather than in the form, which unmounts and remounts as it moves between
|
|
* the chat card and the preview pane: both are views of one entry.
|
|
*
|
|
* Entries are replaced rather than mutated, so a `$derived` reading `submitting` fires;
|
|
* `draft` keeps its identity across a replacement, which is what the form is bound to.
|
|
*/
|
|
#runForms = new SvelteMap<string, PendingRunForm>()
|
|
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
|
|
|
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
|
|
// 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
|
|
// Live session facts (fork vs live workspace) for the GLOBAL system prompt.
|
|
// A resolver set by the session runtime — copilot must not import the
|
|
// sessions modules — and re-read on every system-message rebuild; the send
|
|
// path rebuilds after beforeSend, so a fork committed there is picked up.
|
|
sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined
|
|
// Whether another tab is running a turn on this session right now (sessions
|
|
// wiring, same seam as above). The composer locks on it, and sendRequest
|
|
// refuses on it — the refusal covers the send already in flight when the
|
|
// other tab's run signal arrives, which no disabled input can stop.
|
|
runHeldElsewhereResolver: (() => boolean) | undefined = undefined
|
|
get runHeldElsewhere(): boolean {
|
|
return this.runHeldElsewhereResolver?.() ?? false
|
|
}
|
|
// The page the side panel shows, stamped on each user message. Same seam as above:
|
|
// a page tab is an iframe in its own realm, so the tab model is the only place the
|
|
// chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those.
|
|
activePreviewResolver: (() => GlobalActivePreviewContext | undefined) | undefined = undefined
|
|
// Resolves the workspace this chat operates on. Session chats set it to their
|
|
// own (possibly forked) workspace so the chat targets it WITHOUT switching the
|
|
// global workspaceStore. Undefined for the global side-panel chat, which
|
|
// follows the active workspace. Always read via `operatingWorkspace`.
|
|
workspaceResolver: (() => string | undefined) | undefined = undefined
|
|
|
|
// The workspace every workspace-scoped chat action targets — skills, tool
|
|
// loop, logging, user-message context, message rendering, and commit.
|
|
// Session-resolved when a resolver is set, else the globally-active workspace.
|
|
get operatingWorkspace(): string | undefined {
|
|
return this.workspaceResolver?.() ?? get(workspaceStore)
|
|
}
|
|
|
|
// Fired whenever the active chat id changes away from the one the consumer
|
|
// knows (a "/clear" rotation or a history switch). Session runtimes wire this
|
|
// to keep the session record's chatId aligned — the compare-page handoff
|
|
// (`from_session`) reads it, and a stale id would preselect the previous
|
|
// chat's items. Set here (not imported) to avoid a copilot→sessions cycle.
|
|
onChatRotated: ((chatId: string) => void) | undefined = undefined
|
|
|
|
// Workspace items the CURRENT chat modified via AI tool calls, as
|
|
// `${UserDraftItemKind}:${storagePath}` keys (see modifiedItemsMask.ts).
|
|
// undefined = untracked: only the global side-panel chat (never initialised),
|
|
// which falls back to the show-all bar. Session chats are always tracked (a
|
|
// SvelteSet, even empty) — see loadPastChat/initRuntime — so their Edits
|
|
// surface never claims drafts the session didn't touch. Reactive so the
|
|
// session bar updates as tools record mid-turn.
|
|
modifiedItems = $state<SvelteSet<string> | undefined>(undefined)
|
|
|
|
// Start tracking for a brand-new session chat (empty = "tracked, nothing yet").
|
|
initModifiedItemsTracking() {
|
|
this.modifiedItems = new SvelteSet()
|
|
}
|
|
|
|
// Record an item an AI tool call created/edited/deleted. No-op when untracked
|
|
// (the global singleton never initialises the set), so it stays unaffected.
|
|
recordModifiedItem(itemKind: UserDraftItemKind, storagePath: string) {
|
|
this.modifiedItems?.add(maskKey(itemKind, storagePath))
|
|
}
|
|
|
|
// Un-record an item whose chat-made change was discarded — without this the
|
|
// still-existing deployed item would keep reading as this chat's "Deployed"
|
|
// edit. Persisted immediately: unlike recordModifiedItem (whose persistence
|
|
// rides on the turn's saveChat), a discard can fire from the review dock
|
|
// outside any turn, and waiting would resurrect the entry on reload.
|
|
async removeModifiedItem(itemKind: UserDraftItemKind, storagePath: string) {
|
|
if (!this.modifiedItems?.delete(maskKey(itemKind, storagePath))) return
|
|
await this.#persistModifiedItems()
|
|
}
|
|
|
|
// Move a mask entry to the path a draft actually deployed to. A draft-only
|
|
// flow/app parks at a synthetic `draft_{uuid}` storage path and deploys to
|
|
// its chosen path — without the move, the existence check at the synthetic
|
|
// path fails after reload and the deployed row vanishes from the dock.
|
|
async renameModifiedItem(itemKind: UserDraftItemKind, fromPath: string, toPath: string) {
|
|
if (fromPath === toPath) return
|
|
if (!this.modifiedItems?.delete(maskKey(itemKind, fromPath))) return
|
|
this.modifiedItems.add(maskKey(itemKind, toPath))
|
|
await this.#persistModifiedItems()
|
|
}
|
|
|
|
/** Report one completed provider response's tokens to the workspace usage view.
|
|
* Called per response rather than per turn: a tool loop makes several, each
|
|
* separately billed, and a turn that fails partway through has still spent
|
|
* everything up to that point.
|
|
*
|
|
* Only token counts leave the browser — rates are applied when the usage is
|
|
* read, so a corrected price also corrects everything already recorded. */
|
|
private recordUsage(
|
|
usage: ChatTokenUsage,
|
|
provider: AIProvider,
|
|
model: string,
|
|
workspace: string | undefined
|
|
) {
|
|
// A provider that reports no usage still yields an all-zero report. Recording
|
|
// it would add a $0 row to the usage view, claiming the request cost nothing
|
|
// rather than that it went uncounted.
|
|
if (usage.total === 0 && usage.prompt === 0 && usage.completion === 0) {
|
|
return
|
|
}
|
|
const tokens = billedTokens(usage)
|
|
logAiUsage({
|
|
provider,
|
|
model,
|
|
sessionId: this.sessionId,
|
|
inputTokens: tokens.input,
|
|
cacheReadTokens: tokens.cacheRead,
|
|
cacheWriteTokens: tokens.cacheWrite,
|
|
outputTokens: tokens.output,
|
|
costUsd: usage.cost,
|
|
workspace
|
|
})
|
|
}
|
|
|
|
// Serialized, snapshot-at-write-time persistence: two rapid dock actions
|
|
// would otherwise race their saveChat writes, and the earlier (staler)
|
|
// snapshot could land last — dropping the later mutation until the next
|
|
// turn-end save.
|
|
#maskPersistQueue: Promise<void> = Promise.resolve()
|
|
#persistModifiedItems(): Promise<void> {
|
|
this.#maskPersistQueue = this.#maskPersistQueue.then(() => {
|
|
const { display, jobs } = this.#interruptedSnapshot()
|
|
return (
|
|
this.historyManager
|
|
.saveChat(
|
|
display,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined,
|
|
jobs
|
|
)
|
|
// Swallow (and log) a failed write so it can't wedge the queue as a
|
|
// rejected link — the next persist snapshots the full current set, so
|
|
// a lost write self-heals on the next mutation or turn-end save.
|
|
.catch((e) => console.error('Failed to persist modified-items mask', e))
|
|
)
|
|
})
|
|
return this.#maskPersistQueue
|
|
}
|
|
|
|
// ===== Background jobs (global/sessions chat only) =====
|
|
//
|
|
// A test-run tool that doesn't finish within the inline wait detaches: it
|
|
// returns a "still running" handle to the model and registers the job here.
|
|
// A single poller advances all detached jobs; on completion it fills the tool
|
|
// card and queues a notify-only note for the model's next turn.
|
|
|
|
private isJobNonTerminal(status: ChatJobStatus): boolean {
|
|
// suspended (awaiting approval) and scheduled are non-terminal — the poller
|
|
// MUST keep watching them, else an approval would never clear from the tray.
|
|
return (
|
|
status === 'queued' ||
|
|
status === 'running' ||
|
|
status === 'suspended' ||
|
|
status === 'scheduled'
|
|
)
|
|
}
|
|
|
|
/** Record a job the moment it starts, so the tray shows it while it is still
|
|
* inline-waiting. Idempotent on jobId. The init carries the serializable
|
|
* `resultFormat` (persisted), so completion formatting survives a reload. */
|
|
registerJob = (init: ChatJobInit) => {
|
|
if (this.backgroundJobs.some((j) => j.jobId === init.jobId)) return
|
|
this.backgroundJobs = [
|
|
...this.backgroundJobs,
|
|
{ ...init, createdAt: Date.now(), status: 'queued', detached: false, reported: false }
|
|
]
|
|
// The panel was holding this call's form and the call now has a job: the tab follows
|
|
// the call rather than being left on a form that has already run.
|
|
if (this.isRunFormInPreview?.(init.toolCallId)) {
|
|
this.showRunInPlaceOfForm?.({
|
|
toolCallId: init.toolCallId,
|
|
jobId: init.jobId,
|
|
workspace: init.workspace,
|
|
label: init.label
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Merge a partial update into a tracked job by id. */
|
|
updateJob = (jobId: string, update: Partial<ChatJob>) => {
|
|
const idx = this.backgroundJobs.findIndex((j) => j.jobId === jobId)
|
|
if (idx === -1) return
|
|
const wasTerminal = !this.isJobNonTerminal(this.backgroundJobs[idx].status)
|
|
this.backgroundJobs[idx] = { ...this.backgroundJobs[idx], ...update }
|
|
this.backgroundJobs = [...this.backgroundJobs]
|
|
// Persist on the transition to terminal: a job that completes inside the
|
|
// inline wait never hits the detach/poller persist paths, and would
|
|
// otherwise vanish from the tray on reload.
|
|
if (!wasTerminal && !this.isJobNonTerminal(this.backgroundJobs[idx].status)) {
|
|
void this.#persistBackgroundJobs()
|
|
}
|
|
}
|
|
|
|
/** Mark finished jobs as reviewed (their terminal status was shown in the
|
|
* jobs popover) and persist, so the chip stays relaxed across reloads. */
|
|
markJobsReviewed = (jobIds: string[]) => {
|
|
const ids = new Set(jobIds)
|
|
if (!this.backgroundJobs.some((j) => ids.has(j.jobId) && !j.reviewed)) return
|
|
this.backgroundJobs = this.backgroundJobs.map((j) =>
|
|
ids.has(j.jobId) && !j.reviewed ? { ...j, reviewed: true } : j
|
|
)
|
|
void this.#persistBackgroundJobs()
|
|
}
|
|
|
|
/** A job left the inline wait — hand it to the background poller. */
|
|
markJobDetached = (jobId: string) => {
|
|
this.updateJob(jobId, { detached: true })
|
|
this.#ensureJobPoller()
|
|
void this.#persistBackgroundJobs()
|
|
}
|
|
|
|
/** User-facing cancel from the jobs tray. */
|
|
cancelJob = async (jobId: string) => {
|
|
const job = this.backgroundJobs.find((j) => j.jobId === jobId)
|
|
if (!job) return
|
|
try {
|
|
await JobService.cancelQueuedJob({ workspace: job.workspace, id: jobId, requestBody: {} })
|
|
// Don't mark terminal here: a bare `status: 'canceled'` would (a) leave the
|
|
// `job` snapshot that drives JobStatusIcon stale (badge stuck on running)
|
|
// and (b) make isJobNonTerminal false so the poller stops before it can
|
|
// refresh either. Let the poller observe the canceled CompletedJob and set
|
|
// status + job together; poke it so the tray converges within a tick.
|
|
this.refreshBackgroundJobs()
|
|
} catch (e) {
|
|
console.error('Failed to cancel job', jobId, e)
|
|
sendUserToast('Failed to cancel job', true)
|
|
}
|
|
}
|
|
|
|
/** Remove a finished job from the tray. */
|
|
dismissJob = (jobId: string) => {
|
|
this.backgroundJobs = this.backgroundJobs.filter((j) => j.jobId !== jobId)
|
|
void this.#persistBackgroundJobs()
|
|
}
|
|
|
|
/** Force an immediate background-job poll (e.g. right after an approval) instead
|
|
* of waiting for the next scheduled tick. */
|
|
refreshBackgroundJobs = () => {
|
|
this.#stopJobPoller()
|
|
this.#jobPollDelay = 2000
|
|
void this.#pollBackgroundJobs()
|
|
}
|
|
|
|
#ensureJobPoller() {
|
|
if (this.#jobPollTimer !== undefined) return
|
|
// A poll pass is running (it cleared #jobPollTimer on entry). It reschedules
|
|
// from the current job set when it finishes, so the job that just detached is
|
|
// already covered. Scheduling here instead would create a second timer that the
|
|
// end-of-pass reschedule overwrites WITHOUT clearing — orphaning it into a
|
|
// duplicate, self-perpetuating poll chain. Coalesce into the active pass.
|
|
if (this.#isPolling) return
|
|
if (!this.backgroundJobs.some((j) => j.detached && this.isJobNonTerminal(j.status))) return
|
|
this.#jobPollDelay = 2000
|
|
this.#scheduleJobPoll()
|
|
}
|
|
|
|
#scheduleJobPoll() {
|
|
this.#jobPollTimer = setTimeout(() => void this.#pollBackgroundJobs(), this.#jobPollDelay)
|
|
}
|
|
|
|
#stopJobPoller() {
|
|
if (this.#jobPollTimer !== undefined) {
|
|
clearTimeout(this.#jobPollTimer)
|
|
this.#jobPollTimer = undefined
|
|
}
|
|
}
|
|
|
|
// Guarded entry point for every poll trigger (scheduled tick, #ensureJobPoller,
|
|
// and refreshBackgroundJobs): if a pass is already running, coalesce into it
|
|
// instead of starting a second concurrent chain that would double the poll rate.
|
|
async #pollBackgroundJobs() {
|
|
if (this.#isPolling) return
|
|
this.#isPolling = true
|
|
try {
|
|
await this.#runBackgroundJobsPoll()
|
|
} finally {
|
|
this.#isPolling = false
|
|
}
|
|
}
|
|
|
|
async #runBackgroundJobsPoll() {
|
|
this.#jobPollTimer = undefined
|
|
const gen = this.#jobPollGeneration
|
|
const pending = this.backgroundJobs.filter((j) => j.detached && this.isJobNonTerminal(j.status))
|
|
if (pending.length === 0) return
|
|
|
|
let anyTerminal = false
|
|
for (const job of pending) {
|
|
try {
|
|
// Its own output first, so a run that detached out of the inline wait keeps
|
|
// filling its card. `getJob` alone would freeze a streamed result until the
|
|
// job landed — the partial is only on the updates endpoint.
|
|
let reader = this.#jobUpdateReaders.get(job.jobId)
|
|
if (!reader) {
|
|
reader = createJobUpdateReader(job.jobId, job.workspace)
|
|
this.#jobUpdateReaders.set(job.jobId, reader)
|
|
}
|
|
const update = await reader.poll()
|
|
if (gen !== this.#jobPollGeneration) return
|
|
// Only what this reader has collected: the patch is spread over the card, so
|
|
// naming a field it has nothing for erases output already on it.
|
|
if (update?.logs || update?.resultStream) {
|
|
this.applyToolStatus(job.toolCallId, {
|
|
...(update.logs ? { logs: update.logs } : {}),
|
|
...(update.resultStream ? { resultStream: update.resultStream } : {})
|
|
})
|
|
}
|
|
|
|
// Only when the reader has not already carried them, or when the run may be
|
|
// over — the tail written between the last poll and the end is on the job
|
|
// alone. Otherwise these are logs the tray strips and the card already has,
|
|
// fetched a second time every tick, for every detached job in the chat.
|
|
const wantLogs = !update || update.completed
|
|
const fetched = await JobService.getJob({
|
|
workspace: job.workspace,
|
|
id: job.jobId,
|
|
noLogs: !wantLogs,
|
|
noCode: true
|
|
})
|
|
// The user switched conversations while this getJob was in flight; its
|
|
// result belongs to a chat that's gone. Drop it rather than mutate the
|
|
// newly-loaded one (which re-armed its own poller on load).
|
|
if (gen !== this.#jobPollGeneration) return
|
|
this.#jobPollFailures.delete(job.jobId)
|
|
if (fetched.type === 'CompletedJob') {
|
|
anyTerminal = true
|
|
this.#jobUpdateReaders.delete(job.jobId)
|
|
// The updates can call a landed job unfinished, and the model reads these
|
|
// logs, so a completion seen without them is fetched again.
|
|
const completed = wantLogs
|
|
? (fetched as CompletedJob)
|
|
: ((await JobService.getJob({
|
|
workspace: job.workspace,
|
|
id: job.jobId,
|
|
noLogs: false,
|
|
noCode: true
|
|
})) as CompletedJob)
|
|
if (gen !== this.#jobPollGeneration) return
|
|
this.#onBackgroundJobComplete(job, completed)
|
|
} else {
|
|
// Store the derived status and the trimmed Job together so the tray
|
|
// badge (JobStatusIcon) and the scalar status can never drift.
|
|
this.updateJob(job.jobId, {
|
|
status: deriveChatJobStatus(fetched),
|
|
job: trimJob(fetched)
|
|
})
|
|
}
|
|
} catch (e) {
|
|
// Same generation guard as the success path — a switch during the failing
|
|
// getJob means this result is for a conversation that's gone.
|
|
if (gen !== this.#jobPollGeneration) return
|
|
// A vanished job (404) or repeated failures must not keep the poller
|
|
// alive forever — now that suspended/scheduled are polled too, drain it
|
|
// as failed so isJobNonTerminal lets the poller stop.
|
|
const httpStatus = (e as { status?: number })?.status
|
|
const failures = (this.#jobPollFailures.get(job.jobId) ?? 0) + 1
|
|
this.#jobPollFailures.set(job.jobId, failures)
|
|
if (httpStatus === 404 || failures >= 5) {
|
|
this.#jobPollFailures.delete(job.jobId)
|
|
this.#jobUpdateReaders.delete(job.jobId)
|
|
// Vanished (404) or unreachable after repeated polls. Mark it failed WITH
|
|
// a snapshot + tool-card patch (mirroring #onBackgroundJobComplete) so
|
|
// neither the tray badge nor the launching tool card stays frozen on
|
|
// "running" — a bare `status: 'failure'` with no `job` would render the
|
|
// orange queued badge (JobsSegment's `!job.job` fallback). The synthetic
|
|
// failed CompletedJob keeps the `success`-key discriminant so JobStatusIcon
|
|
// and deriveChatJobStatus agree. No model note/auto-resume: a vanished job
|
|
// isn't a meaningful completion to react to (usually transient infra).
|
|
const gone = {
|
|
type: 'CompletedJob',
|
|
id: job.jobId,
|
|
success: false,
|
|
canceled: false
|
|
} as unknown as CompletedJob
|
|
this.updateJob(job.jobId, { status: 'failure', reported: true, job: trimJob(gone) })
|
|
this.applyToolStatus(job.toolCallId, {
|
|
content: 'Background job could not be retrieved (it may have been removed)',
|
|
error: `Job ${job.jobId} was unreachable`,
|
|
isLoading: false
|
|
})
|
|
anyTerminal = true
|
|
} else {
|
|
console.error('Failed to poll background job', job.jobId, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (anyTerminal) {
|
|
void this.#persistBackgroundJobs()
|
|
}
|
|
|
|
// Reschedule while anything is still in flight, backing off up to 5s.
|
|
if (this.backgroundJobs.some((j) => j.detached && this.isJobNonTerminal(j.status))) {
|
|
this.#jobPollDelay = Math.min(this.#jobPollDelay + 1000, 5000)
|
|
this.#scheduleJobPoll()
|
|
}
|
|
|
|
// Something finished this cycle — if the chat is idle, react to it now
|
|
// instead of waiting for the user's next message. Fire-and-forget so the
|
|
// poller loop above isn't blocked by the turn.
|
|
if (anyTerminal) void this.#maybeAutoResumeFromJobs()
|
|
}
|
|
|
|
#onBackgroundJobComplete(job: ChatJob, completed: CompletedJob) {
|
|
const status = deriveChatJobStatus(completed)
|
|
this.updateJob(job.jobId, {
|
|
status,
|
|
durationMs: completed.duration_ms,
|
|
reported: true,
|
|
job: trimJob(completed)
|
|
})
|
|
// If the launching tool stamped a resultFormat, reconstruct its shaped card +
|
|
// model text so the detached path reports the same contract the inline path
|
|
// would (row-capped rows, friendly datatable errors) — even after a reload,
|
|
// since resultFormat is persisted on the job. A canceled job skips formatting:
|
|
// its card is the neutral "canceled" state, not a result.
|
|
const formatted =
|
|
status === 'canceled' || !job.resultFormat
|
|
? undefined
|
|
: formatChatJobCompletion(completed, job.resultFormat)
|
|
// Fill the tool card that launched it (we run outside a turn here). isLoading is
|
|
// normally already false — processToolCall clears it when the launching tool
|
|
// returns — but a card restored from a mid-turn checkpoint never saw that return,
|
|
// so only this patch can stop it spinning.
|
|
this.applyToolStatus(job.toolCallId, {
|
|
...(formatted?.card ?? completedJobToolStatus(completed)),
|
|
isLoading: false
|
|
})
|
|
// A user-canceled job needs no model note or auto-resume: the user stopped it
|
|
// deliberately, so announcing it (as "FAILED", since a canceled job isn't a
|
|
// success) or burning a turn on it would be noise.
|
|
if (status === 'canceled') return
|
|
// Queue a completion note for the model. Delivered on the next turn —
|
|
// either the user's next message or an idle auto-resume (fired by the poller).
|
|
this.pendingJobNotes = [
|
|
...this.pendingJobNotes,
|
|
backgroundJobCompletionNote(job.jobId, job.label, completed, formatted?.llmText)
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Stage 2 wake: when a background job finishes and the chat is otherwise idle,
|
|
* start a turn on the user's behalf so the model reacts to the result (reports
|
|
* it, continues the plan) instead of waiting for the next manual message. The
|
|
* rich completion note reaches the model via the pendingJobNotes preamble in
|
|
* sendRequest; the visible bubble is just a short, clearly-automated line.
|
|
*
|
|
* Bounded so it can't run away: fires only when idle (no in-flight turn) and
|
|
* only when notes exist — and sendRequest drains the notes, so a turn that
|
|
* doesn't spawn a new job leaves nothing to re-trigger on. A turn that DOES
|
|
* spawn another job resumes again when that one finishes, which is the point.
|
|
*/
|
|
#autoResumeRetry: ReturnType<typeof setTimeout> | undefined
|
|
|
|
#scheduleAutoResumeRetry() {
|
|
clearTimeout(this.#autoResumeRetry)
|
|
this.#autoResumeRetry = setTimeout(() => {
|
|
this.#autoResumeRetry = undefined
|
|
void this.#maybeAutoResumeFromJobs()
|
|
}, 5_000)
|
|
}
|
|
|
|
async #maybeAutoResumeFromJobs() {
|
|
if (this.#autoResuming) return
|
|
// Global/sessions chat only (the only mode with a jobs tray + preamble).
|
|
if (this.mode !== AIMode.GLOBAL) return
|
|
// Mid-turn: the notes will ride that turn's preamble, so don't start another.
|
|
if (this.loading) return
|
|
if (this.pendingJobNotes.length === 0) return
|
|
// Nothing to continue (empty chat), or the user is mid-compose — don't
|
|
// clobber their draft or auto-send it. Their eventual send carries the notes.
|
|
if (this.messages.length === 0 || this.instructions.trim()) return
|
|
// Another tab is driving: the synthetic send would only be refused, and
|
|
// the instructions staged below would then block every later auto-resume
|
|
// in this tab. The notes stay pending; re-checked shortly, because the
|
|
// hold can clear silently (staleness after a driver crash) with nothing
|
|
// else to fire this. When the driver instead ends its turn normally, its
|
|
// own resume carries the notes and this tab's catch-up clears the local
|
|
// copy — the re-check then finds nothing and stands down.
|
|
if (this.runHeldElsewhere) {
|
|
this.#scheduleAutoResumeRetry()
|
|
return
|
|
}
|
|
this.#autoResuming = true
|
|
try {
|
|
const count = this.pendingJobNotes.length
|
|
this.instructions =
|
|
count === 1 ? 'A background job just finished.' : `${count} background jobs just finished.`
|
|
await this.sendRequest({ synthetic: true })
|
|
} catch (e) {
|
|
console.error('Auto-resume after background job failed', e)
|
|
} finally {
|
|
this.#autoResuming = false
|
|
}
|
|
}
|
|
|
|
// Serialized snapshot-at-write persistence, mirroring #persistModifiedItems.
|
|
// Omits the modified-items mask so a concurrent mask write isn't clobbered
|
|
// (saveChat keeps the prior mask when it is undefined).
|
|
#jobPersistQueue: Promise<void> = Promise.resolve()
|
|
#persistBackgroundJobs(): Promise<void> {
|
|
this.#jobPersistQueue = this.#jobPersistQueue.then(() => {
|
|
const { display, jobs } = this.#interruptedSnapshot()
|
|
return this.historyManager
|
|
.saveChat(display, this.messages, this.contextUsage, undefined, jobs)
|
|
.catch((e) => console.error('Failed to persist background jobs', e))
|
|
})
|
|
return this.#jobPersistQueue
|
|
}
|
|
|
|
/** Reset background-job state on conversation switch. */
|
|
private clearBackgroundJobs() {
|
|
this.#stopJobPoller()
|
|
// Invalidate any in-flight poll so its post-await continuation can't write
|
|
// into the conversation we're switching to.
|
|
this.#jobPollGeneration++
|
|
clearTimeout(this.#autoResumeRetry)
|
|
this.#autoResumeRetry = undefined
|
|
this.#jobUpdateReaders.clear()
|
|
this.backgroundJobs = []
|
|
this.pendingJobNotes = []
|
|
}
|
|
|
|
/** Merge a status patch into the tool card identified by tool_call_id, or
|
|
* create it. Shared by the per-turn setToolStatus callback and the background
|
|
* job poller (which runs outside a turn). */
|
|
applyToolStatus = (id: string, metadata?: Partial<ToolDisplayMessage>) => {
|
|
const existingIdx = this.displayMessages.findIndex(
|
|
(m) => m.role === 'tool' && m.tool_call_id === id
|
|
)
|
|
if (existingIdx !== -1) {
|
|
const existing = this.displayMessages[existingIdx] as ToolDisplayMessage
|
|
if (existing.content.length === 0 && metadata?.error) {
|
|
this.displayMessages[existingIdx].content = metadata.error
|
|
}
|
|
this.displayMessages[existingIdx] = {
|
|
...existing,
|
|
...(metadata || {})
|
|
} as ToolDisplayMessage
|
|
} else {
|
|
const newMessage: ToolDisplayMessage = {
|
|
role: 'tool',
|
|
tool_call_id: id,
|
|
content: metadata?.content ?? metadata?.error ?? '',
|
|
...(metadata || {})
|
|
}
|
|
this.displayMessages.push(newMessage)
|
|
}
|
|
}
|
|
|
|
// The `ai_skill` resources this user turned on for the operating workspace,
|
|
// advertised in the GLOBAL system prompt and surfaced as slash commands in
|
|
// session chat. Loaded asynchronously when entering GLOBAL mode and again
|
|
// whenever the picker changes the selection; the system message is rebuilt
|
|
// once they resolve.
|
|
globalSkills = $state<AiSkillListItem[]>([])
|
|
private globalSkillsRefreshId = 0
|
|
|
|
// External MCP servers the user connected (resources of type `mcp`). Loaded
|
|
// asynchronously alongside skills; the MCP tools are only registered when
|
|
// this is non-empty, so a workspace with no connection pays no schema cost
|
|
// for them on every chat-loop iteration.
|
|
mcpServers = $state<McpServer[]>([])
|
|
private mcpServersRefreshId = 0
|
|
|
|
// The GLOBAL prompt's path conventions and folder ACLs, for this chat's operating
|
|
// workspace (`GlobalPromptIdentity`). Resolved asynchronously alongside skills, never
|
|
// read from the ambient user store.
|
|
private globalIdentity = $state<GlobalPromptIdentity | undefined>(undefined)
|
|
private globalIdentityRefreshId = 0
|
|
|
|
// Built-in session-chat slash commands, listed in the command picker
|
|
// alongside workspace skills. Unlike a skill, these run locally and never
|
|
// reach the model; the submit path intercepts them first, so they shadow any
|
|
// workspace skill of the same name.
|
|
readonly sessionBuiltinCommands: ChatCommandItem[] = [
|
|
{
|
|
name: COMPACT_COMMAND_NAME,
|
|
description: 'Summarize the conversation to free up context',
|
|
kind: 'action'
|
|
},
|
|
{
|
|
name: CLEAR_COMMAND_NAME,
|
|
description: 'Clear the conversation and start a new chat',
|
|
kind: 'action'
|
|
}
|
|
]
|
|
|
|
// Built-ins followed by workspace skills, with any skill whose name collides
|
|
// with a built-in dropped. Built-ins win — they already shadow same-named
|
|
// skills at execution (the submit interception), so listing both would offer
|
|
// a row that cannot run. Two skills may still share a name; the picker keys
|
|
// those by path and the submit path declines to guess between them.
|
|
sessionCommands: ChatCommandItem[] = $derived([
|
|
...this.sessionBuiltinCommands,
|
|
...this.globalSkills
|
|
.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
|
|
.map((s) => ({ ...s, kind: 'skill' as const }))
|
|
])
|
|
|
|
allowedModes: Record<AIMode, boolean> = $derived({
|
|
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)
|
|
})
|
|
|
|
open = $derived(chatState.size > 0)
|
|
|
|
// one token is ~ 4 characters
|
|
private estimateMessagesTokens = (messages: ChatCompletionMessageParam[]) => {
|
|
return messages.reduce((acc, message) => {
|
|
const tokenPerCharacter = 4
|
|
if (typeof message.content === 'string') {
|
|
acc += message.content.length / tokenPerCharacter
|
|
} else if (Array.isArray(message.content)) {
|
|
// Multimodal content: chars/4 for the text parts, a flat estimate per image
|
|
// (a base64 data URL is huge as text but only ~1.1-1.6k tokens as vision input,
|
|
// so JSON.stringify here would overcount by orders of magnitude).
|
|
for (const part of message.content as any[]) {
|
|
if (part?.type === 'text') acc += (part.text?.length ?? 0) / tokenPerCharacter
|
|
else if (part?.type === 'image_url') acc += IMAGE_TOKEN_ESTIMATE
|
|
else acc += JSON.stringify(part).length / tokenPerCharacter
|
|
}
|
|
} else if (message.content) {
|
|
acc += JSON.stringify(message.content).length / tokenPerCharacter
|
|
}
|
|
if (message.role === 'assistant' && message.tool_calls) {
|
|
acc += JSON.stringify(message.tool_calls).length / tokenPerCharacter
|
|
}
|
|
return acc
|
|
}, 0)
|
|
}
|
|
|
|
/** Estimated tokens of the parts the messages array doesn't carry: the
|
|
* current system prompt and tool definitions. */
|
|
private estimateOverheadTokens = () => {
|
|
const tokenPerCharacter = 4
|
|
const systemTokens =
|
|
typeof this.systemMessage.content === 'string'
|
|
? this.systemMessage.content.length / tokenPerCharacter
|
|
: 0
|
|
const toolTokens =
|
|
this.tools.length > 0
|
|
? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter
|
|
: 0
|
|
return systemTokens + toolTokens
|
|
}
|
|
|
|
/**
|
|
* chars/4 estimate of the full context as currently stored: messages plus
|
|
* the system prompt and tool definitions the next request would carry.
|
|
* Recomputed from scratch at each read — never accumulated — so errors
|
|
* don't compound.
|
|
*/
|
|
private estimateWholeContextTokens = () =>
|
|
Math.round(this.estimateMessagesTokens(this.messages) + this.estimateOverheadTokens())
|
|
|
|
/**
|
|
* How full the context is right now — the single fallback rule, shared by
|
|
* the compaction trigger and the usage indicator: the provider's exact
|
|
* report when one describes the current history, a fresh estimate
|
|
* otherwise. Estimating at the read point (rather than writing estimates
|
|
* into `contextUsage`) means no code path that mutates history can leave
|
|
* a stale or missing value behind.
|
|
*/
|
|
contextTokens = $derived.by(() => this.contextUsage ?? this.estimateWholeContextTokens())
|
|
|
|
/**
|
|
* Drop-oldest compaction. Deletes messages from the front of the STORED
|
|
* history (the API messages — displayMessages keep the full conversation
|
|
* for the user) until at least `tokensToFree` estimated tokens are freed
|
|
* AND the remaining history starts on a user message: a leading tool
|
|
* result or assistant turn would dangle without the messages that
|
|
* introduced it. The most recent user message is never dropped. Returns
|
|
* the estimated tokens freed.
|
|
*/
|
|
compactOldestMessages = (tokensToFree: number): number => {
|
|
const last = this.messages.length - 1
|
|
let drop = 0
|
|
let freed = 0
|
|
while (drop < last) {
|
|
if (freed >= tokensToFree && this.messages[drop].role === 'user') {
|
|
break
|
|
}
|
|
freed += this.estimateMessagesTokens([this.messages[drop]])
|
|
drop++
|
|
}
|
|
if (drop === 0) {
|
|
return 0
|
|
}
|
|
this.messages = this.messages.slice(drop)
|
|
// User display messages carry the index of their API message so restart
|
|
// can rewind to it; re-base them on the compacted history. A message whose
|
|
// API counterpart was dropped goes negative — deliberately NOT clamped to
|
|
// 0, which would alias it to the first surviving message and let
|
|
// storedImages hand a retry that message's images. Negative reads as
|
|
// "counterpart gone": storedImages finds nothing there, and restart maps
|
|
// it to an empty history (everything before it was dropped too, since
|
|
// compaction only removes prefixes).
|
|
// A summary row also carries its API index (for orphan detection) — re-base it
|
|
// too so it reads "counterpart gone" once drop-oldest removes the summary.
|
|
this.displayMessages = this.displayMessages.map((m) =>
|
|
m.role === 'user' || (m.role === 'summary' && m.index !== undefined)
|
|
? { ...m, index: m.index! - drop }
|
|
: m
|
|
)
|
|
return freed
|
|
}
|
|
|
|
/**
|
|
* Core summarize + rewrite, shared by automatic and manual compaction. Sends
|
|
* the prefix to the summarizer, then replaces the summarized prefix with a
|
|
* single summary message in `messages` (as a user message) and
|
|
* `displayMessages` (as a `summary` boundary). Surviving tail user messages
|
|
* have their restart `index` re-based onto the new history: the summary
|
|
* occupies slot 0, so a tail user message that was at `keepFrom` lands at slot
|
|
* 1. `displayKeepFrom` is where the kept tail begins in `displayMessages`.
|
|
*
|
|
* Owns only the `compacting` flag and the history rewrite; callers own trigger
|
|
* policy (circuit breaker, gates) and persistence. Returns the outcome —
|
|
* 'aborted' is a user Stop (history left untouched), distinct from 'error'.
|
|
*/
|
|
private runSummarization = async (
|
|
prefix: ChatCompletionMessageParam[],
|
|
tail: ChatCompletionMessageParam[],
|
|
keepFrom: number,
|
|
displayKeepFrom: number,
|
|
abortController: AbortController
|
|
): Promise<'ok' | 'empty' | 'aborted' | 'error'> => {
|
|
this.compacting = true
|
|
try {
|
|
// Cap the summarizer's output at the budget already reserved for the
|
|
// summary. Without a cap the model's default max_tokens applies, and the
|
|
// Anthropic SDK rejects non-streaming requests whose max_tokens implies
|
|
// >10 minutes of generation (~21k tokens) before anything is sent.
|
|
const raw = await getNonStreamingCompletion(
|
|
[
|
|
// Strip image blobs from the summarizer input — the summary text stands in
|
|
// for them, so re-sending base64 to the summarizer only wastes tokens.
|
|
...stripImagePartsFromMessages(sanitizeToolCallArguments(prefix)),
|
|
{ role: 'user', content: getCompactionSummaryPrompt() }
|
|
],
|
|
abortController,
|
|
{ maxTokensCap: SUMMARY_OUTPUT_RESERVE_TOKENS }
|
|
)
|
|
const formatted = formatCompactSummary(raw ?? '')
|
|
if (!formatted) {
|
|
return 'empty'
|
|
}
|
|
|
|
// Files attached to folded-away messages ride the summary: the transcript
|
|
// is their durable home, so dropping the referencing message without
|
|
// carrying them would lose the attachment entirely. Deduped by stable id:
|
|
// several folded turns can carry the identical file, and the summary must
|
|
// list/keep it once.
|
|
const carriedById = new Map<string, AttachedTextFile>()
|
|
for (const m of this.displayMessages.slice(0, displayKeepFrom)) {
|
|
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
|
for (const f of withAttachedTextFileIds(m.files)) carriedById.set(f.id!, f)
|
|
}
|
|
}
|
|
const carriedFiles = [...carriedById.values()]
|
|
const filesNote =
|
|
carriedFiles.length > 0
|
|
? '\n\nThe user attached these files earlier in this conversation; they are still readable via `read_file` / `search_files` (pass the file id):\n' +
|
|
carriedFiles
|
|
.map((f) => `- ${sanitizeAttachmentName(f.name)} (file id: ${f.id})`)
|
|
.join('\n')
|
|
: ''
|
|
|
|
this.messages = [
|
|
{ role: 'user', content: buildSummaryMessageContent(formatted) + filesNote },
|
|
...tail
|
|
]
|
|
|
|
// Replace the summarized display prefix with the boundary marker and
|
|
// re-base the surviving tail's restart indices (the summary occupies
|
|
// slot 0, so the tail now starts at slot 1).
|
|
this.displayMessages = [
|
|
{
|
|
role: 'summary',
|
|
content: formatted,
|
|
// The summary API message sits at slot 0 of the rewritten history; track
|
|
// it so a later drop-oldest that removes it can orphan the carried files.
|
|
index: 0,
|
|
files: carriedFiles.length > 0 ? carriedFiles : undefined
|
|
},
|
|
...this.displayMessages
|
|
.slice(displayKeepFrom)
|
|
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
|
|
]
|
|
|
|
// The provider report described the pre-compaction history; the new
|
|
// history is much smaller, so clear it and let readers re-estimate.
|
|
this.contextUsage = undefined
|
|
return 'ok'
|
|
} catch (err) {
|
|
if (abortController.signal.aborted) {
|
|
return 'aborted'
|
|
}
|
|
console.error('Conversation summarization failed', err)
|
|
return 'error'
|
|
} finally {
|
|
this.compacting = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Summary-based partial compaction. Summarizes the older PREFIX of the stored
|
|
* history into a single user message and keeps the recent tail verbatim,
|
|
* bringing the history down to roughly the target ratio while preserving the
|
|
* intent, decisions, and recent work that drop-oldest would discard.
|
|
*
|
|
* The tail grows from the most recent message until it fills `tailBudget`,
|
|
* then snaps forward to a user-message boundary (a leading tool/assistant
|
|
* message would dangle without the turn that introduced it). The summary
|
|
* replaces the prefix in BOTH `messages` (as a user message) and
|
|
* `displayMessages` (as a `summary` boundary); surviving tail user messages
|
|
* have their restart `index` re-based onto the new history.
|
|
*
|
|
* Returns true on success. Returns false — caller falls back to drop-oldest —
|
|
* when summarization isn't worthwhile or fails (user abort, empty summary, or
|
|
* the circuit breaker being tripped).
|
|
*/
|
|
private summarizeAndCompact = async (contextWindow: number): Promise<boolean> => {
|
|
if (this.consecutiveCompactionFailures >= MAX_CONSECUTIVE_COMPACTION_FAILURES) {
|
|
return false
|
|
}
|
|
const abortController = this.abortController
|
|
if (!abortController) {
|
|
return false
|
|
}
|
|
|
|
const tailBudget =
|
|
contextWindow * COMPACTION_TARGET_RATIO -
|
|
SUMMARY_OUTPUT_RESERVE_TOKENS -
|
|
this.estimateOverheadTokens()
|
|
if (tailBudget <= 0) {
|
|
return false
|
|
}
|
|
|
|
const last = this.messages.length - 1
|
|
if (last < 1) {
|
|
return false
|
|
}
|
|
|
|
// Grow the tail from the most recent message downward while it fits the
|
|
// budget; always keep at least the last message.
|
|
let keepFrom = last
|
|
let tailTokens = 0
|
|
for (let i = last; i >= 1; i--) {
|
|
const t = this.estimateMessagesTokens([this.messages[i]])
|
|
if (i < last && tailTokens + t > tailBudget) {
|
|
break
|
|
}
|
|
tailTokens += t
|
|
keepFrom = i
|
|
}
|
|
// The tail must start on a user message the transcript also shows — move the
|
|
// boundary forward over leading tool/assistant messages, and over synthetic
|
|
// user messages that carry no display entry (the image follow-ups
|
|
// appendPendingToolImages injects). Landing on one would slice `messages`
|
|
// and `displayMessages` at different turns, silently dropping the cards in
|
|
// between from the visible history.
|
|
const shownUserIndices = new Set(
|
|
this.displayMessages.filter((m) => m.role === 'user').map((m) => m.index)
|
|
)
|
|
while (keepFrom < last && !shownUserIndices.has(keepFrom)) {
|
|
keepFrom++
|
|
}
|
|
|
|
const prefix = this.messages.slice(0, keepFrom)
|
|
const tail = this.messages.slice(keepFrom)
|
|
if (prefix.length < MIN_PREFIX_MESSAGES_TO_SUMMARIZE || tail.length === 0) {
|
|
return false
|
|
}
|
|
|
|
// Exact index, never >=: a miss must fail the compaction, because resolving
|
|
// to a later turn would slice the transcript short of the kept API tail.
|
|
const displayKeepFrom = this.displayMessages.findIndex(
|
|
(m) => m.role === 'user' && m.index === keepFrom
|
|
)
|
|
if (displayKeepFrom === -1) {
|
|
this.consecutiveCompactionFailures++
|
|
return false
|
|
}
|
|
|
|
const result = await this.runSummarization(
|
|
prefix,
|
|
tail,
|
|
keepFrom,
|
|
displayKeepFrom,
|
|
abortController
|
|
)
|
|
if (result === 'ok') {
|
|
this.consecutiveCompactionFailures = 0
|
|
return true
|
|
}
|
|
// 'aborted' is a user Stop during the in-flight summary — a turn cancel, not
|
|
// a compaction failure, so it doesn't count toward the circuit breaker.
|
|
if (result === 'empty' || result === 'error') {
|
|
this.consecutiveCompactionFailures++
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Manual compaction (the `/compact` session command): summarize the ENTIRE
|
|
* stored history into a single summary message and keep nothing verbatim, so
|
|
* the next message continues from the summary alone. Unlike the automatic
|
|
* trigger it ignores the context-window budget, the circuit breaker, and the
|
|
* prefix-size gate — the user asked for it explicitly — and runs on its own
|
|
* abort controller so the Stop button (`cancel`) can interrupt the in-flight
|
|
* summary, leaving history untouched.
|
|
*/
|
|
compactManually = async (): Promise<void> => {
|
|
if (this.loading) {
|
|
return
|
|
}
|
|
// A summary round-trip only pays off once there's a prior exchange to fold
|
|
// in; a single message (or none) has nothing to compact.
|
|
if (this.messages.length < 2) {
|
|
sendUserToast('Nothing to compact yet.')
|
|
return
|
|
}
|
|
|
|
const abortController = new AbortController()
|
|
this.abortController = abortController
|
|
this.loading = true
|
|
let result: 'ok' | 'empty' | 'aborted' | 'error' = 'error'
|
|
try {
|
|
// Everything is the prefix, nothing is kept verbatim: keepFrom and
|
|
// displayKeepFrom point past the end so the kept tail is empty.
|
|
result = await this.runSummarization(
|
|
[...this.messages],
|
|
[],
|
|
this.messages.length,
|
|
this.displayMessages.length,
|
|
abortController
|
|
)
|
|
switch (result) {
|
|
case 'ok':
|
|
// Reconcile file registrations with the compacted transcript — the
|
|
// summary message carries the folded-away turns' files forward.
|
|
this.#syncMessageFiles()
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
sendUserToast('Conversation compacted.')
|
|
break
|
|
case 'empty':
|
|
sendUserToast('Compaction produced an empty summary — conversation left unchanged.', true)
|
|
break
|
|
case 'error':
|
|
sendUserToast('Failed to compact the conversation.', true)
|
|
break
|
|
// 'aborted' (user Stop): history untouched, no toast.
|
|
}
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
|
|
// Flush a message typed while compaction ran. Mirrors the send-turn
|
|
// epilogue (loading gated its capture): auto-send after a successful
|
|
// compaction or a deliberate user cancel — the user is ready to move on —
|
|
// while a failed/empty compaction or a programmatic cancel leaves it queued.
|
|
if ((result === 'ok' || this.wasCancelledByUser()) && this.#hasQueuedMessage()) {
|
|
const next = this.#takeQueue()
|
|
const accepted = await this.sendRequest({
|
|
instructions: next.draft.text,
|
|
images: next.draft.images,
|
|
files: next.draft.files,
|
|
contextOverride: next.context,
|
|
queued: true
|
|
})
|
|
if (accepted === false) {
|
|
this.#restoreQueue(next)
|
|
}
|
|
}
|
|
}
|
|
|
|
loadApiTools = async () => {
|
|
try {
|
|
this.apiTools = await loadApiTools()
|
|
if (this.mode === AIMode.API) {
|
|
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading api tools', err)
|
|
this.apiTools = []
|
|
}
|
|
}
|
|
|
|
/** enter_plan_mode never qualifies: YOLO means "stop asking and run it", and a research
|
|
* posture inverts that. Every accept path asks here rather than carrying its own copy. */
|
|
private autoAcceptsTool = (toolName: string | undefined) => toolName !== ENTER_PLAN_MODE_TOOL
|
|
|
|
/** Asked before the confirmation wait is skipped, so a tool the posture will not answer
|
|
* for still gets a card rather than running unasked. */
|
|
shouldAutoAcceptTool = (toolName?: string) =>
|
|
this.autoAcceptToolConfirmationsActive && this.autoAcceptsTool(toolName)
|
|
|
|
// Request confirmation from user for a tool call
|
|
requestConfirmation = (toolId: string, toolName?: string): Promise<boolean> => {
|
|
if (this.autoAcceptToolConfirmationsActive) {
|
|
return Promise.resolve(this.autoAcceptsTool(toolName))
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
this.confirmationCallbacks.set(toolId, { resolve, toolName })
|
|
})
|
|
}
|
|
|
|
// Handle confirmation response for a specific tool
|
|
handleToolConfirmation = (toolId: string, confirmed: boolean) => {
|
|
const confirmationCallback = this.confirmationCallbacks.get(toolId)
|
|
if (confirmationCallback) {
|
|
confirmationCallback.resolve(confirmed)
|
|
this.confirmationCallbacks.delete(toolId)
|
|
}
|
|
}
|
|
|
|
private acceptPendingToolConfirmations = () => {
|
|
for (const { resolve, toolName } of this.confirmationCallbacks.values()) {
|
|
resolve(this.autoAcceptsTool(toolName))
|
|
}
|
|
this.confirmationCallbacks.clear()
|
|
}
|
|
|
|
private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => {
|
|
if (flowHelpers?.hasPendingChanges()) {
|
|
flowHelpers.acceptAllModuleActions()
|
|
}
|
|
}
|
|
|
|
private resolvePendingPlanCard = (toolName: string, confirmed: boolean) => {
|
|
for (const [toolId, cb] of this.confirmationCallbacks) {
|
|
if (cb.toolName === toolName) {
|
|
cb.resolve(confirmed)
|
|
this.confirmationCallbacks.delete(toolId)
|
|
}
|
|
}
|
|
}
|
|
|
|
setAutonomyMode = (mode: AIAutonomyMode) => {
|
|
const enteringPlan = mode === AIAutonomyMode.PLAN && this.autonomyMode !== AIAutonomyMode.PLAN
|
|
const leavingPlan = mode !== AIAutonomyMode.PLAN && this.autonomyMode === AIAutonomyMode.PLAN
|
|
if (enteringPlan) {
|
|
this.prePlanAutonomyMode = this.autonomyMode
|
|
this.planMode.startRound()
|
|
} else if (mode !== AIAutonomyMode.PLAN) {
|
|
this.prePlanAutonomyMode = undefined
|
|
this.planMode.resetBlocks()
|
|
}
|
|
this.autonomyMode = mode
|
|
persistAutonomyMode(mode)
|
|
|
|
if (enteringPlan) {
|
|
this.resolvePendingPlanCard(ENTER_PLAN_MODE_TOOL, true)
|
|
} else if (leavingPlan) {
|
|
// Opting into YOLO means "run it"; leaving plan mode any other way is not a sign-off.
|
|
this.resolvePendingPlanCard(EXIT_PLAN_MODE_TOOL, mode === AIAutonomyMode.YOLO)
|
|
}
|
|
if (this.autoAcceptToolConfirmationsActive) {
|
|
this.acceptPendingToolConfirmations()
|
|
}
|
|
if (this.autoAcceptEditsActive) {
|
|
this.acceptPendingFlowEdits()
|
|
}
|
|
}
|
|
|
|
setAutoAcceptToolConfirmations = (enabled: boolean) => {
|
|
this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT)
|
|
}
|
|
|
|
// Re-read the autonomy mode from the user-scoped key when the logged-in
|
|
// email resolves or changes. Claims legacy un-namespaced keys on first
|
|
// login; falls back to the safe default when logged out so we never leave a
|
|
// prior user's YOLO mode active. Registered only for the module-level
|
|
// singleton (constructed before the email is known) — per-session managers
|
|
// are constructed post-login and read the scoped value directly.
|
|
hydrateUserScopedAutonomy = () => {
|
|
migrateLegacyAutonomyKeys()
|
|
this.autonomyMode = getPersistedAutonomyMode()
|
|
this.prePlanAutonomyMode = undefined
|
|
this.planMode.resetRound()
|
|
}
|
|
|
|
applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => {
|
|
if (this.autoAcceptEditsActive && opts?.mode === 'revert') {
|
|
return
|
|
}
|
|
|
|
const effectiveOpts =
|
|
this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply'
|
|
? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts)
|
|
: opts
|
|
await this.scriptEditorApplyCode?.(code, effectiveOpts)
|
|
}
|
|
|
|
requestUserQuestion = (
|
|
toolId: string,
|
|
_question: UserQuestionDisplay
|
|
): Promise<string[] | undefined> => {
|
|
return new Promise((resolve) => {
|
|
this.userQuestionCallbacks.set(toolId, resolve)
|
|
})
|
|
}
|
|
|
|
/** Returns whether the answer was delivered: a card restored from history
|
|
* still looks parked but its resolver is gone with the old page, so callers
|
|
* holding the only copy of the answer must not discard it on a false. */
|
|
handleUserQuestionAnswer = (toolId: string, choices: string[]): boolean => {
|
|
const callback = this.userQuestionCallbacks.get(toolId)
|
|
if (!callback) {
|
|
return false
|
|
}
|
|
|
|
// Display-only readback for the collapsed tool-header: a compact comma list.
|
|
// The model-facing return (bare string / newline-bulleted) is built by the
|
|
// tool fn from the resolved choices below.
|
|
const answerSummary = choices.join(', ')
|
|
|
|
this.displayMessages = this.displayMessages.map((message) => {
|
|
if (message.role === 'tool' && message.tool_call_id === toolId && message.userQuestion) {
|
|
return {
|
|
...message,
|
|
content: `Asked: ${message.userQuestion.question} — ${answerSummary}`,
|
|
isLoading: false,
|
|
userQuestion: {
|
|
...message.userQuestion,
|
|
selectedChoices: choices
|
|
}
|
|
}
|
|
}
|
|
return message
|
|
})
|
|
|
|
callback(choices)
|
|
this.userQuestionCallbacks.delete(toolId)
|
|
return true
|
|
}
|
|
|
|
requestRunArgs = (
|
|
toolId: string,
|
|
form: RunFormDisplay,
|
|
opts?: { autoAccepted?: boolean }
|
|
): Promise<Record<string, any> | undefined> => {
|
|
// The tool reads the schema before it asks, so a stop during that read drains the
|
|
// callbacks and settles the card before this runs. Installing one then would park
|
|
// the turn on a form the settled card no longer renders, leaving nothing able to
|
|
// resolve it. The controller is per-turn, so a later turn still opens.
|
|
if (this.abortController?.signal.aborted) {
|
|
// Settle the form the tool attached after the stop. Its card is about to stop
|
|
// loading without ever having rendered, and settledToolDisplay only reaches a
|
|
// loading one — so this is the last point the schema, with the script's own
|
|
// password and file defaults, can be dropped. No card copy: the stop path writes
|
|
// what the row says.
|
|
this.#settleRunForm(toolId, undefined)
|
|
return Promise.resolve(undefined)
|
|
}
|
|
// Ahead of the wait, not of the stop above: the caller settled this form before
|
|
// attaching it, so its card renders no fields and nothing here could ever resolve.
|
|
if (opts?.autoAccepted) {
|
|
return Promise.resolve(form.args)
|
|
}
|
|
// Seeded from the caller's copy, before the card renders: the file arguments on
|
|
// `displayMessages` are redacted, so a draft built from those would open the form on
|
|
// the marker rather than on the bytes the model proposed.
|
|
const entry = this.#runFormEntry(toolId, form)
|
|
return new Promise((resolve) => {
|
|
this.#runForms.set(toolId, { ...entry, resolve })
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The entry a run form edits through, created on first mount and shared by every later one.
|
|
*
|
|
* Deep snapshots, never the message's own values: those are `$state` proxies off
|
|
* `displayMessages`, and SchemaForm edits args and schema in place (it reorders the
|
|
* schema on mount), so anything shallower writes each keystroke — a nested password
|
|
* included — into the persisted transcript.
|
|
*/
|
|
#runFormEntry = (toolId: string, runForm: RunFormDisplay): PendingRunForm => {
|
|
const existing = this.#runForms.get(toolId)
|
|
if (existing) return existing
|
|
const draft = $state({
|
|
args: ($state.snapshot(runForm.args) ?? {}) as Record<string, any>,
|
|
schema: ($state.snapshot(runForm.schema) ?? {}) as Record<string, any>
|
|
})
|
|
const entry: PendingRunForm = { draft, submitting: false }
|
|
this.#runForms.set(toolId, entry)
|
|
return entry
|
|
}
|
|
|
|
runFormDraft = (toolId: string, runForm: RunFormDisplay): RunFormDraft =>
|
|
this.#runFormEntry(toolId, runForm).draft
|
|
|
|
markRunFormStarted = (toolId: string) => this.#patchRunForm(toolId, { started: true })
|
|
|
|
// A form restored from history has an entry once it mounts, but no resolve: the loop
|
|
// that opened it is gone.
|
|
isRunFormPending = (toolId: string): boolean => !!this.#runForms.get(toolId)?.resolve
|
|
|
|
isRunFormSubmitting = (toolId: string): boolean => this.#runForms.get(toolId)?.submitting ?? false
|
|
|
|
/** False when a submit is already in flight for this call, so the caller can drop a
|
|
* second one rather than mint a second set of ephemeral secret variables for it. */
|
|
beginRunFormSubmit = (toolId: string): boolean => {
|
|
const entry = this.#runForms.get(toolId)
|
|
if (!entry || entry.submitting) return false
|
|
this.#runForms.set(toolId, { ...entry, submitting: true })
|
|
return true
|
|
}
|
|
|
|
endRunFormSubmit = (toolId: string) => {
|
|
const entry = this.#runForms.get(toolId)
|
|
if (entry?.submitting) this.#runForms.set(toolId, { ...entry, submitting: false })
|
|
}
|
|
|
|
/** Whether any form of this chat is still waiting on the user. Asked instead of looking
|
|
* the form up in the panel's DOM: when the preview holds it, the card is collapsed and
|
|
* the only mounted copy is outside the panel — where a DOM query would miss it and let
|
|
* Escape discard what has been typed. */
|
|
get hasPendingRunForm(): boolean {
|
|
for (const entry of this.#runForms.values()) if (entry.resolve) return true
|
|
return false
|
|
}
|
|
|
|
/** False when the form is no longer pending, so the caller can say so instead of
|
|
* leaving its submit button spinning on a run that will never start. */
|
|
handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => {
|
|
if (!this.isRunFormPending(toolId)) return false
|
|
this.#settleRunForm(toolId, args)
|
|
return true
|
|
}
|
|
|
|
handleRunFormCancel = (toolId: string) => {
|
|
// The card's own copy is settled here rather than only in the tool's fn, which a form
|
|
// restored from history no longer has: Cancel is that card's one way out, and while it
|
|
// stays active the whole session reads as needs-confirmation (getSessionChatStatus asks
|
|
// pendingUserAction before loading). Clearing isLoading is part of settling — canceled
|
|
// alone unmounts the form but leaves the card shimmering.
|
|
this.#settleRunForm(toolId, undefined, (runForm) => ({
|
|
isLoading: false,
|
|
error: 'Cancelled by user',
|
|
content: `Run of "${runForm.path}" cancelled by user`
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* The one way a run form stops waiting on the user: `submitted` is the arguments to run
|
|
* with, `undefined` a cancellation.
|
|
*
|
|
* `card` is for a settler that also owns what the row reads — pressing Cancel does, a
|
|
* stopped turn leaves it to settledToolDisplay.
|
|
*/
|
|
#settleRunForm = (
|
|
toolId: string,
|
|
submitted: Record<string, any> | undefined,
|
|
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
|
|
) => {
|
|
const entry = this.#runForms.get(toolId)
|
|
// Its draft holds whatever was typed into the form, a minted password included.
|
|
this.#runForms.delete(toolId)
|
|
// Cancelled, so no run follows it into that tab (a submitted one is handed over by
|
|
// registerJob instead) — take the tab with it rather than leaving a dead form open.
|
|
if (submitted === undefined) this.closeRunForm?.(toolId)
|
|
const cancelledArgs =
|
|
submitted === undefined && entry ? this.#settledFormArgs(entry) : undefined
|
|
this.#patchRunForm(
|
|
toolId,
|
|
submitted ? { submitted: true } : { canceled: true },
|
|
cancelledArgs ? (runForm) => ({ ...card?.(runForm), parameters: cancelledArgs }) : card
|
|
)
|
|
entry?.resolve?.(submitted)
|
|
}
|
|
|
|
/**
|
|
* What a form that never ran leaves on its card. A run writes its own arguments there once
|
|
* it has them and a cancellation never reaches that write, so without this the card keeps
|
|
* the proposal it was published on — naming a secret the field had already replaced with a
|
|
* reference. A reference stands, anything still literal does not.
|
|
*/
|
|
#settledFormArgs = (entry: PendingRunForm): Record<string, any> =>
|
|
redactFileArgs(redactSecretArgs(entry.draft.args, entry.draft.schema), entry.draft.schema)
|
|
|
|
#patchRunForm = (
|
|
toolId: string,
|
|
patch: Partial<RunFormDisplay>,
|
|
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
|
|
) => {
|
|
this.displayMessages = this.displayMessages.map((message) =>
|
|
message.role === 'tool' && message.tool_call_id === toolId && message.runForm
|
|
? {
|
|
...message,
|
|
...card?.(message.runForm),
|
|
runForm: settledRunForm({ ...message.runForm, ...patch })
|
|
}
|
|
: message
|
|
)
|
|
}
|
|
|
|
setAiChatInput(aiChatInput: AIChatInput | null) {
|
|
this.aiChatInput = aiChatInput
|
|
}
|
|
|
|
/** Queue the message typed while a turn is streaming. There is only ever
|
|
* one queued message; pressing Enter again appends the new text as another
|
|
* line so it all goes out as a single message, and its images accumulate
|
|
* alongside it. */
|
|
queueMessage(
|
|
text: string,
|
|
images: AttachedImage[] = [],
|
|
context?: ContextElement[],
|
|
files: AttachedTextFile[] = []
|
|
) {
|
|
const trimmed = text.trim()
|
|
// An attachment-only or context-only draft is still a message; only a fully
|
|
// empty send is ignored (mirrors the idle empty-send guard).
|
|
if (!trimmed && images.length === 0 && files.length === 0 && (context?.length ?? 0) === 0) {
|
|
return
|
|
}
|
|
if (trimmed) {
|
|
this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed
|
|
}
|
|
// The queue is a message draft like any other: attachments join under the
|
|
// draft rules (fold, caps) — repeated submissions during one stream
|
|
// aggregate into a single queued message.
|
|
const droppedImages = images.length > 0 ? this.#queuedDraft.addImages(images) : 0
|
|
if (droppedImages > 0) {
|
|
sendUserToast(`Only the first ${MAX_ATTACHED_IMAGES} images are kept.`, true)
|
|
}
|
|
const droppedFiles = files.length > 0 ? this.#queuedDraft.addFiles(files).droppedAtCap : 0
|
|
if (droppedFiles > 0) {
|
|
sendUserToast(`Only the first ${MAX_ATTACHED_FILES} files are kept.`, true)
|
|
}
|
|
// Pin the context snapshot to the queued message. Several prompts can
|
|
// queue during one stream and each pinned the selection at its press —
|
|
// union by identity so a later press doesn't drop an earlier prompt's
|
|
// chips (all pinned entries ride the single flushed turn together).
|
|
if (context && context.length > 0) {
|
|
const merged = [...(this.queuedContext ?? [])]
|
|
for (const c of context) {
|
|
if (!merged.some((m) => isSameContextElement(m, c))) {
|
|
merged.push(c)
|
|
}
|
|
}
|
|
this.queuedContext = merged
|
|
}
|
|
}
|
|
|
|
/** Whether anything is waiting in the queue — an attachment-only or
|
|
* context-only message has empty text. */
|
|
#hasQueuedMessage(): boolean {
|
|
return !this.#queuedDraft.isEmpty || (this.queuedContext?.length ?? 0) > 0
|
|
}
|
|
|
|
/** Detach the queue for sending. The draft lanes and context always leave together. */
|
|
#takeQueue(): QueuedEntry {
|
|
const taken = {
|
|
draft: this.#queuedDraft.take(),
|
|
context: this.queuedContext
|
|
}
|
|
this.queuedContext = undefined
|
|
return taken
|
|
}
|
|
|
|
#clearQueue() {
|
|
this.#queuedDraft.clear()
|
|
this.queuedContext = undefined
|
|
}
|
|
|
|
/** Put a taken queue back after an auto-send bailed before becoming a turn.
|
|
* Merged, not replaced: the user may have queued a follow-up while the
|
|
* auto-send was in preflight, and clobbering it would silently lose it — the
|
|
* taken entry's lanes land ahead of the follow-up's (they were written
|
|
* first) and both entries' pinned contexts are unioned. */
|
|
#restoreQueue(queued: QueuedEntry) {
|
|
this.#queuedDraft.prepend({
|
|
text: queued.draft.text,
|
|
images: queued.draft.images,
|
|
files: queued.draft.files
|
|
})
|
|
if (queued.context?.length) {
|
|
const merged = [...queued.context]
|
|
for (const c of this.queuedContext ?? []) {
|
|
if (!merged.some((m) => isSameContextElement(m, c))) {
|
|
merged.push(c)
|
|
}
|
|
}
|
|
this.queuedContext = merged
|
|
}
|
|
}
|
|
|
|
/** Put a draft's pinned DOM selector chips back as the live selection, so the
|
|
* restored draft and the selection stay coherent (its instruction targets the
|
|
* element it was written for). No-op when the draft pinned no DOM chips, so a
|
|
* plain-text draft leaves the live selection untouched.
|
|
*
|
|
* `keepExisting` when the restored text was merged into a draft the user was
|
|
* already writing: that draft's own chips must survive alongside, or its
|
|
* instruction — still sitting in the composer — would be retargeted at this
|
|
* draft's element. Otherwise the restore replaces, since any chip selected
|
|
* since belongs to a draft that is being replaced too. */
|
|
#restoreDomContext(context: ContextElement[] | undefined, keepExisting = false) {
|
|
const domChips = (context ?? []).filter((c) => c.type === 'app_dom_selector')
|
|
if (domChips.length === 0) return
|
|
const existing = keepExisting
|
|
? (this.contextManager?.getSelectedContext().filter((c) => c.type === 'app_dom_selector') ??
|
|
[])
|
|
: []
|
|
this.contextManager?.clearSelectedDomElements()
|
|
// addSelectedDomElement dedups on (selector, appPath), so a chip both drafts
|
|
// share collapses to one.
|
|
for (const c of [...domChips, ...existing]) {
|
|
this.contextManager?.addSelectedDomElement(c)
|
|
}
|
|
}
|
|
|
|
/** Give back the mentions a send carried when its text returns to the composer,
|
|
* so its `@` tokens still have entries to bind to. Additive, unlike the DOM
|
|
* restore above: dropping the entries this send did not carry would strand the
|
|
* tokens naming them in a draft whose text now shares the same composer.
|
|
*
|
|
* `originMode` is the mode the send was submitted in, and is required: the
|
|
* composer only consumes in GLOBAL, so reading the mode at restore time would
|
|
* strand a send whose mode changed mid-turn and resurrect chips for one that
|
|
* never consumed. Every caller states which mode it means. */
|
|
#restoreMentionContext(context: ContextElement[] | undefined, originMode: AIMode) {
|
|
if (originMode !== AIMode.GLOBAL) return
|
|
const mentions = (context ?? []).filter(isMentionContext)
|
|
if (mentions.length === 0) return
|
|
const selection = this.contextManager?.getSelectedContext() ?? []
|
|
const missing = mentions.filter((m) => !selection.some((s) => isSameContextElement(s, m)))
|
|
if (missing.length === 0) return
|
|
this.contextManager?.setSelectedContext([...selection, ...missing])
|
|
}
|
|
|
|
/** Send `text` as a turn, or queue it when one is already streaming. Callers
|
|
* that send programmatically (an editor button, an arriving hand-off) must go
|
|
* through this rather than `sendRequest`: a second concurrent loop shares this
|
|
* manager's abort controller and transcript, so the two interleave and Stop
|
|
* halts only one. It is the rule the composer already follows.
|
|
*
|
|
* Gated on `sendInFlight` as well as `loading`: `loading` only rises after a
|
|
* send's attachment upkeep, so between the two a click would slip past. */
|
|
sendOrQueue(text: string) {
|
|
if (this.loading || this.sendInFlight) {
|
|
this.queueMessage(text)
|
|
return
|
|
}
|
|
void this.sendRequest({ instructions: text })
|
|
}
|
|
|
|
/** Remove the queued message and put it back into the input, images included. */
|
|
dequeueMessage() {
|
|
if (!this.#hasQueuedMessage()) {
|
|
return
|
|
}
|
|
const queued = this.#takeQueue()
|
|
const mergedIntoDraft = this.restoreToInput(
|
|
queued.draft.text,
|
|
queued.draft.images,
|
|
queued.draft.files
|
|
)
|
|
// The queued draft pinned its own DOM context; restore it so sending from
|
|
// the composer targets the element the draft was written for, not whatever
|
|
// is selected now. If its text was prepended onto an existing draft, that
|
|
// draft's chips are kept too — both instructions now share one composer.
|
|
this.#restoreDomContext(queued.context, mergedIntoDraft)
|
|
// The queue aggregates several enqueues into one entry and records no
|
|
// originating mode, so the mode now is the closest signal available. A
|
|
// recall after a mid-turn mode switch can therefore miss a restore.
|
|
this.#restoreMentionContext(queued.context, this.mode)
|
|
}
|
|
|
|
/** Put what the user typed back where they can see it: into the input
|
|
* when it's mounted, otherwise back into the queue so it reappears with
|
|
* the chat panel instead of being silently dropped. */
|
|
private restoreToInput(
|
|
text: string,
|
|
images: AttachedImage[] = [],
|
|
files: AttachedTextFile[] = []
|
|
): boolean {
|
|
if (this.aiChatInput) {
|
|
return this.aiChatInput.prependText(text, images, files) === true
|
|
}
|
|
// Merge onto anything already queued (see #restoreQueue) — replacing would
|
|
// silently drop a message queued while this one was in flight.
|
|
this.#queuedDraft.prepend({ text, images, files })
|
|
return false
|
|
}
|
|
|
|
focusInput() {
|
|
if (this.aiChatInput) {
|
|
this.aiChatInput.focusInput()
|
|
}
|
|
}
|
|
|
|
updateMode(currentMode: AIMode) {
|
|
if (
|
|
!this.allowedModes[currentMode] &&
|
|
Object.keys(this.allowedModes).filter((k) => this.allowedModes[k]).length === 1
|
|
) {
|
|
const firstKey = Object.keys(this.allowedModes).filter((k) => this.allowedModes[k])[0]
|
|
this.changeMode(firstKey as AIMode)
|
|
}
|
|
}
|
|
|
|
private getScriptWorkspaceMutationTarget = (): WorkspaceMutationTarget => {
|
|
const path = this.scriptEditorOptions?.path
|
|
const workspacePath = isWorkspacePath(path) ? path : undefined
|
|
return {
|
|
kind: 'script',
|
|
path: workspacePath,
|
|
deployed:
|
|
workspacePath !== undefined && this.scriptEditorOptions?.lastDeployedCode !== undefined
|
|
}
|
|
}
|
|
|
|
private getFlowWorkspaceMutationTarget = (): WorkspaceMutationTarget => {
|
|
return {
|
|
kind: 'flow',
|
|
path: this.flowOptions?.path,
|
|
deployed:
|
|
!!this.flowOptions?.path &&
|
|
!!this.flowOptions.lastDeployedFlow &&
|
|
!this.flowOptions.lastDeployedFlow.draft_only
|
|
}
|
|
}
|
|
|
|
changeMode(
|
|
mode: AIMode,
|
|
pendingPrompt?: string,
|
|
options?: {
|
|
closeScriptSettings?: boolean
|
|
lang?: ScriptLang | 'bunnative'
|
|
isPreprocessor?: boolean
|
|
workflowAsCode?: boolean
|
|
}
|
|
) {
|
|
if (!isAIModeVisible(mode)) return
|
|
// A session chat is GLOBAL for its whole life, and the plan gate reads that mode: moving
|
|
// it lifts the gate on a session the user still has set to Plan.
|
|
if (this.isSessionChat && mode !== AIMode.GLOBAL) {
|
|
console.error(`Refusing to move a session chat to ${mode} mode: sessions are GLOBAL-only.`)
|
|
return
|
|
}
|
|
if (mode === AIMode.SCRIPT && !tryGetCurrentModel()) return
|
|
this.mode = mode
|
|
this.pendingPrompt = pendingPrompt ?? ''
|
|
if (mode === AIMode.SCRIPT) {
|
|
const currentModel = getCurrentModel()
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
const lang = options?.lang ?? this.scriptEditorOptions?.lang ?? 'bun'
|
|
const workflowAsCode =
|
|
options?.workflowAsCode ??
|
|
(options?.lang ? false : (this.scriptEditorOptions?.workflowAsCode ?? false))
|
|
const context = this.contextManager.getSelectedContext()
|
|
this.systemMessage = prepareScriptSystemMessage(
|
|
currentModel,
|
|
lang,
|
|
{ isPreprocessor: options?.isPreprocessor, workflowAsCode },
|
|
customPrompt
|
|
)
|
|
this.systemMessage.content = this.systemMessage.content
|
|
this.tools = [...prepareScriptTools(currentModel, lang, context)]
|
|
this.helpers = {
|
|
getScriptOptions: () => {
|
|
return {
|
|
code: this.scriptEditorOptions?.getCode() ?? '',
|
|
lang: lang,
|
|
path: this.scriptEditorOptions?.path ?? '',
|
|
args: this.scriptEditorOptions?.args ?? {}
|
|
}
|
|
},
|
|
getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget,
|
|
applyCode: (code: string, opts?: ReviewChangesOpts) => {
|
|
return this.applyScriptEditorCode(code, opts)
|
|
},
|
|
getLintErrors: () => {
|
|
if (this.scriptEditorGetLintErrors) {
|
|
return this.scriptEditorGetLintErrors()
|
|
}
|
|
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
|
}
|
|
}
|
|
if (options?.closeScriptSettings) {
|
|
const closeComponent = triggerablesByAi['close-script-builder-settings']
|
|
if (closeComponent) {
|
|
closeComponent.onTrigger?.()
|
|
}
|
|
}
|
|
} else if (mode === AIMode.FLOW) {
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
this.systemMessage = prepareFlowSystemMessage(customPrompt)
|
|
this.systemMessage.content = this.systemMessage.content
|
|
this.appendFlowAiAgentProviders(this.systemMessage)
|
|
this.tools = [...flowTools]
|
|
this.helpers = {
|
|
...(this.flowAiChatHelpers ?? {}),
|
|
getWorkspaceMutationTarget: this.getFlowWorkspaceMutationTarget
|
|
}
|
|
} else if (mode === AIMode.NAVIGATOR) {
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
this.systemMessage = prepareNavigatorSystemMessage(customPrompt)
|
|
this.tools = [this.changeModeTool, ...navigatorTools]
|
|
this.helpers = {}
|
|
} else if (mode === AIMode.ASK) {
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
this.systemMessage = prepareAskSystemMessage(customPrompt)
|
|
this.tools = [...askTools]
|
|
this.helpers = {}
|
|
} else if (mode === AIMode.API) {
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
this.systemMessage = prepareApiSystemMessage(customPrompt)
|
|
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
|
|
this.helpers = {}
|
|
} else if (mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
void this.refreshGlobalIdentity()
|
|
void this.refreshGlobalSkills()
|
|
void this.refreshMcpServers()
|
|
} else if (mode === AIMode.APP) {
|
|
const customPrompt = getCombinedCustomPrompt(mode)
|
|
this.systemMessage = prepareAppSystemMessage(customPrompt)
|
|
this.tools = [...getAppTools()]
|
|
this.helpers = this.appAiChatHelpers
|
|
}
|
|
}
|
|
|
|
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
|
|
// the system message so the next chat-loop iteration advertises them. Ignore
|
|
// stale resolves so workspace changes cannot overwrite newer skills.
|
|
// Build the global-mode system message, tools, and helpers, layering on the
|
|
// pipeline surface when a /pipeline editor has registered helpers. Centralized
|
|
// so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent —
|
|
// each rebuild would otherwise drop the pipeline augmentation the others added.
|
|
//
|
|
// Public because it is purely local, unlike `changeMode(GLOBAL)`, which also
|
|
// fires the three network refreshes.
|
|
configureGlobalMode = () => {
|
|
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
|
previewTools: this.isSessionChat,
|
|
user: this.globalIdentity,
|
|
skills: this.globalSkills,
|
|
mcpServers: this.mcpServers
|
|
})
|
|
const sessionCtx = this.sessionContextResolver?.()
|
|
if (sessionCtx) {
|
|
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
|
}
|
|
const baseHelpers: GlobalToolHelpers = {
|
|
// A session targets its own fixed (possibly forked) workspace, so capture it for
|
|
// permission gating. The global side-panel chat follows the live navigation
|
|
// workspace instead, so leave it unset there — allowedOpenPages reads the store.
|
|
...(this.isSessionChat
|
|
? {
|
|
sessionId: this.sessionId,
|
|
operatingWorkspace: this.operatingWorkspace,
|
|
artifacts: this.artifacts,
|
|
getChatId: () => this.historyManager.getCurrentChatId(),
|
|
openArtifact: this.openArtifact
|
|
}
|
|
: {}),
|
|
testActiveFlow: async (storagePath: string, args?: Record<string, any>, memoryId?: string) =>
|
|
this.flowEditorFor(storagePath)?.testFlow(args, memoryId),
|
|
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
|
|
attachedFiles: this.attachedFiles,
|
|
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
|
|
setUserInstructions: (instructions: string) => {
|
|
const prompts = getUserCustomPrompts()
|
|
if (instructions.trim()) {
|
|
prompts[AIMode.GLOBAL] = instructions
|
|
} else {
|
|
delete prompts[AIMode.GLOBAL]
|
|
}
|
|
setUserCustomPrompts(prompts)
|
|
this.rebuildGlobalSystemMessage()
|
|
}
|
|
}
|
|
const pipeline = this.pipelineAiChatHelpers
|
|
const mcpTools = createMcpTools(this.mcpServers)
|
|
if (pipeline) {
|
|
systemMessage.content += getPipelinePromptSection(pipeline.getPipelineContext())
|
|
this.tools = [
|
|
...globalToolsFor({ sessionPreview: this.isSessionChat }),
|
|
...pipelineTools,
|
|
...mcpTools
|
|
]
|
|
this.helpers = { ...baseHelpers, pipeline }
|
|
} else {
|
|
this.tools = [...globalToolsFor({ sessionPreview: this.isSessionChat }), ...mcpTools]
|
|
this.helpers = baseHelpers
|
|
}
|
|
this.systemMessage = systemMessage
|
|
this.syncArtifactsSession()
|
|
}
|
|
|
|
refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => {
|
|
const refreshId = ++this.globalSkillsRefreshId
|
|
const skills = await loadWorkspaceSkills(workspace)
|
|
if (refreshId !== this.globalSkillsRefreshId) {
|
|
return
|
|
}
|
|
// Newest-wins is not enough: a refresh for the workspace just left can still
|
|
// hold the newest id, and installing it would advertise that workspace's
|
|
// skills to a chat now acting elsewhere. Same check the identity and MCP
|
|
// refreshes make.
|
|
this.globalSkills = workspace === (this.operatingWorkspace ?? '') ? skills : []
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
}
|
|
}
|
|
|
|
// Same shape as refreshGlobalSkills. An identity that resolves after the operating
|
|
// workspace moved describes the workspace left behind, so it is dropped, not installed.
|
|
refreshGlobalIdentity = async (workspace = this.operatingWorkspace ?? '') => {
|
|
const refreshId = ++this.globalIdentityRefreshId
|
|
const identity = await resolveGlobalPromptIdentity(workspace)
|
|
if (refreshId !== this.globalIdentityRefreshId) {
|
|
return
|
|
}
|
|
this.globalIdentity = workspace === (this.operatingWorkspace ?? '') ? identity : undefined
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
}
|
|
}
|
|
|
|
// Same shape as refreshGlobalSkills: rebuild GLOBAL mode once the connected
|
|
// MCP servers resolve so the next chat-loop iteration advertises their tools,
|
|
// ignoring stale resolves so a workspace change cannot overwrite newer ones.
|
|
//
|
|
// A server is a path, and the workspace a call runs against is read at call
|
|
// time, so a listing that resolves after the operating workspace moved must be
|
|
// dropped rather than installed: the same path in the workspace switched to is
|
|
// a different server, and one the user has not opted into.
|
|
refreshMcpServers = async (workspace = this.operatingWorkspace ?? '') => {
|
|
const refreshId = ++this.mcpServersRefreshId
|
|
const servers = await loadMcpServers(workspace)
|
|
if (refreshId !== this.mcpServersRefreshId) {
|
|
return
|
|
}
|
|
// Dropping the stale answer is not enough on its own: leaving the previous
|
|
// workspace's servers installed would go on advertising its paths against
|
|
// the workspace switched to.
|
|
this.mcpServers = workspace === (this.operatingWorkspace ?? '') ? servers : []
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
}
|
|
}
|
|
|
|
// The workspace's AI provider resources and their models exist only at run time, so they are
|
|
// appended once the catalog resolves. The chat loop re-reads this.systemMessage on every
|
|
// iteration, so a send that beats the fetch still picks them up on the next one.
|
|
private appendFlowAiAgentProviders = async (target: ChatCompletionSystemMessageParam) => {
|
|
const catalog = await getAiAgentProviderCatalog(this.operatingWorkspace)
|
|
// Flow mode's tools are flowTools, which carry no askUserQuestion.
|
|
const section = formatAiAgentProvidersPrompt(catalog, { canAskUser: false })
|
|
// A mode switch or a rebuild since the fetch started owns the message now.
|
|
if (section === '' || this.systemMessage !== target) {
|
|
return
|
|
}
|
|
this.systemMessage = { ...target, content: `${target.content}\n\n${section}` }
|
|
}
|
|
|
|
// Rebuild the GLOBAL system message in place so an updated user instruction (persisted by
|
|
// the update_user_instructions tool) is picked up on the next chat-loop iteration, which
|
|
// re-reads this.systemMessage via a getter.
|
|
rebuildGlobalSystemMessage = () => {
|
|
if (this.mode !== AIMode.GLOBAL) {
|
|
return
|
|
}
|
|
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
|
previewTools: this.isSessionChat,
|
|
user: this.globalIdentity,
|
|
skills: this.globalSkills,
|
|
mcpServers: this.mcpServers
|
|
})
|
|
// Preserve the session-state and active pipeline-editor augmentations that
|
|
// configureGlobalMode adds — otherwise update_user_instructions (which calls
|
|
// this) would drop them mid-session.
|
|
const sessionCtx = this.sessionContextResolver?.()
|
|
if (sessionCtx) {
|
|
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
|
}
|
|
const pipeline = this.pipelineAiChatHelpers
|
|
if (pipeline) {
|
|
systemMessage.content += getPipelinePromptSection(pipeline.getPipelineContext())
|
|
}
|
|
this.systemMessage = systemMessage
|
|
}
|
|
|
|
private expandGlobalSkillCommand = (instructions: string): string => {
|
|
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
|
|
return instructions
|
|
}
|
|
// Accepts a bare name or a whole resource path: names are what people type,
|
|
// but the picker inserts the path when two folders answer to the same name.
|
|
// Unicode-aware rather than `\w`, which is ASCII-only — a resource path may
|
|
// hold any word character, and the picker can insert one the user must then
|
|
// be able to send (`f/équipe/deploy`).
|
|
const match = /^\/([\p{L}\p{N}_\-/]+)(?:\s+([\s\S]*))?$/u.exec(instructions)
|
|
if (!match) {
|
|
return instructions
|
|
}
|
|
// A path identifies one skill; a name shared by two would otherwise silently
|
|
// apply instructions the user did not choose, so it is left unexpanded.
|
|
const byPath = this.globalSkills.find((s) => s.path === match[1])
|
|
const matches = byPath ? [byPath] : this.globalSkills.filter((s) => s.name === match[1])
|
|
if (matches.length !== 1) {
|
|
return instructions
|
|
}
|
|
const rest = match[2]?.trim()
|
|
const use = `Use the skill at "${matches[0].path}".`
|
|
return rest ? `${use} ${rest}` : use
|
|
}
|
|
|
|
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
|
|
|
|
private changeModeTool = {
|
|
def: {
|
|
type: 'function' as const,
|
|
function: {
|
|
name: 'change_mode',
|
|
description:
|
|
'Change the AI mode to the one specified. Script mode is used to create scripts. Flow mode is used to create flows.' +
|
|
(isGlobalAiEnabled()
|
|
? ' Global mode is used to inspect workspace scripts and flows and create draft changes.'
|
|
: '') +
|
|
' Navigator mode is used to navigate the application and help the user find what they are looking for. API mode is used to make API calls to the Windmill backend.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
mode: {
|
|
type: 'string',
|
|
description: 'The mode to change to',
|
|
enum: [
|
|
'script',
|
|
'flow',
|
|
...(isGlobalAiEnabled() ? ['global'] : []),
|
|
'navigator',
|
|
'API'
|
|
]
|
|
},
|
|
pendingPrompt: {
|
|
type: 'string',
|
|
description: 'The prompt to send to the new mode to fulfill the user request',
|
|
default: ''
|
|
}
|
|
},
|
|
required: ['mode']
|
|
}
|
|
}
|
|
},
|
|
fn: async ({ args, toolId, toolCallbacks }) => {
|
|
if (!isAIMode(args.mode) || !isAIModeVisible(args.mode)) {
|
|
throw new Error(`AI mode "${args.mode}" is not enabled`)
|
|
}
|
|
toolCallbacks.setToolStatus(toolId, { content: 'Switching to ' + args.mode + ' mode...' })
|
|
this.changeMode(args.mode, args.pendingPrompt, {
|
|
closeScriptSettings: true
|
|
})
|
|
toolCallbacks.setToolStatus(toolId, { content: 'Switched to ' + args.mode + ' mode' })
|
|
return 'Mode changed to ' + args.mode
|
|
}
|
|
}
|
|
|
|
openChat = () => {
|
|
// Nothing may open the docked pane in a workspace that hid the assistant.
|
|
if (get(copilotInfo).workspaceDisabled) {
|
|
return
|
|
}
|
|
chatState.size = this.savedSize > 0 ? this.savedSize : DEFAULT_SIZE
|
|
localStorage.setItem('ai-chat-open', 'true')
|
|
}
|
|
|
|
closeChat = () => {
|
|
this.savedSize = chatState.size
|
|
chatState.size = 0
|
|
localStorage.setItem('ai-chat-open', 'false')
|
|
}
|
|
|
|
toggleOpen = () => {
|
|
if (chatState.size === 0 && get(copilotInfo).workspaceDisabled) {
|
|
return
|
|
}
|
|
if (chatState.size > 0) {
|
|
this.savedSize = chatState.size
|
|
}
|
|
chatState.size = chatState.size === 0 ? (this.savedSize > 0 ? this.savedSize : DEFAULT_SIZE) : 0
|
|
localStorage.setItem('ai-chat-open', chatState.size === 0 ? 'false' : 'true')
|
|
}
|
|
|
|
askAi = (
|
|
prompt: string,
|
|
options: { withCode?: boolean; withDiff?: boolean } = {
|
|
withCode: true,
|
|
withDiff: false
|
|
}
|
|
) => {
|
|
if (this.scriptEditorOptions) {
|
|
this.contextManager.setAskAiContext(options)
|
|
}
|
|
this.instructions = prompt
|
|
this.sendRequest({
|
|
removeDiff: options.withDiff,
|
|
addBackCode: options.withCode === false
|
|
})
|
|
if (options.withDiff) {
|
|
this.scriptEditorShowDiffMode?.()
|
|
}
|
|
}
|
|
|
|
retryRequest = (messageIndex: number) => {
|
|
const message = this.displayMessages[messageIndex]
|
|
if (message && message.role === 'user') {
|
|
this.restartGeneration(messageIndex)
|
|
message.error = false
|
|
} else {
|
|
throw new Error('No user message found at the specified index')
|
|
}
|
|
}
|
|
|
|
private getLastUserMessage = () => {
|
|
for (let i = this.displayMessages.length - 1; i >= 0; i--) {
|
|
const message = this.displayMessages[i]
|
|
if (message.role === 'user') {
|
|
return message
|
|
}
|
|
}
|
|
}
|
|
|
|
private flagLastMessageAsError = () => {
|
|
const lastUserMessage = this.getLastUserMessage()
|
|
if (lastUserMessage) {
|
|
lastUserMessage.error = true
|
|
}
|
|
}
|
|
|
|
// The transcript an interrupted turn leaves behind: the stored history, the
|
|
// tool-paired prefix of the turn's completed steps (a dangling tool call
|
|
// would make providers reject the next request), and the partial answer text
|
|
// when it isn't already inside that prefix. Pure — the caller decides whether
|
|
// this becomes the live transcript or only a persisted checkpoint.
|
|
private interruptedTurnMessages = (
|
|
collectedMessages: ChatCompletionMessageParam[],
|
|
partialReply: string,
|
|
// Passed only by the mid-turn checkpoint, whose result must outlive its turn.
|
|
// A turn committed for a follow-up is still live, so it would rather truncate
|
|
// a half-run batch and rerun it than read results nothing produced.
|
|
snapshot?: {
|
|
/** Result to synthesize for the calls of a batch caught mid-execution. */
|
|
interruptedToolContent: string
|
|
/** Images a tool has produced that the turn has not yet turned into a
|
|
* message (see appendPendingToolImages). */
|
|
bufferedImages?: ChatCompletionMessageParam
|
|
}
|
|
): { messages: ChatCompletionMessageParam[]; keptPartialReply: boolean } => {
|
|
const prefix = snapshot
|
|
? closeInterruptedToolBatch(collectedMessages, snapshot.interruptedToolContent)
|
|
: truncateToToolPairedPrefix(collectedMessages)
|
|
// partialReply can be stale — equal to text already committed inside the
|
|
// prefix — so only append when new. A snapshot is exempt: it passes only
|
|
// live streaming text, which is never in the prefix, and identical text can
|
|
// legitimately recur across iterations where content alone cannot judge it.
|
|
const lastCommittedText = [...prefix]
|
|
.reverse()
|
|
.find(
|
|
(m): m is ChatCompletionMessageParam & { content: string } =>
|
|
m.role === 'assistant' && typeof m.content === 'string' && !!m.content.trim()
|
|
)?.content
|
|
const keptPartialReply =
|
|
!!partialReply.trim() && (!!snapshot || partialReply !== lastCommittedText)
|
|
// Images sit between the batch that produced them and whatever the model
|
|
// said next, matching where appendPendingToolImages puts them live.
|
|
const tail = snapshot?.bufferedImages ? [...prefix, snapshot.bufferedImages] : prefix
|
|
return {
|
|
messages: keptPartialReply
|
|
? [...this.messages, ...tail, { role: 'assistant', content: partialReply }]
|
|
: [...this.messages, ...tail],
|
|
keptPartialReply
|
|
}
|
|
}
|
|
|
|
// Commit an interrupted turn's usable output as context for a follow-up.
|
|
// A reasoning-only interrupt instead drops its stuck-open bubble.
|
|
private commitInterruptedTurn = (
|
|
collectedMessages: ChatCompletionMessageParam[],
|
|
partialReply: string
|
|
) => {
|
|
const { messages, keptPartialReply } = this.interruptedTurnMessages(
|
|
collectedMessages,
|
|
partialReply
|
|
)
|
|
this.messages = messages
|
|
if (!keptPartialReply) {
|
|
const last = this.displayMessages[this.displayMessages.length - 1]
|
|
if (last?.role === 'assistant' && !last.content.trim() && !!last.reasoning) {
|
|
this.displayMessages = this.displayMessages.slice(0, -1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Roll a turn that produced nothing usable back out of the transcript and
|
|
// hand its text back to the composer for editing/resending. `restoreToInput`
|
|
// is false when a queued message is about to take over (a user cancel with
|
|
// something queued) — then the rolled-back prompt is dropped rather than
|
|
// shoved back into the input, so the handoff to the queued message is clean.
|
|
private restoreUnsentTurn = async (
|
|
displayLenAfterUser: number,
|
|
modelLenAfterUser: number,
|
|
instructions: string,
|
|
pastes: PasteAttachment[],
|
|
restoreToInput: boolean = true,
|
|
images: AttachedImage[] = [],
|
|
files: AttachedTextFile[] = []
|
|
): Promise<boolean> => {
|
|
this.displayMessages = this.displayMessages.slice(0, displayLenAfterUser - 1)
|
|
this.messages = this.messages.slice(0, modelLenAfterUser - 1)
|
|
// The rolled-back turn's files must not stay registered: the message
|
|
// referencing them is gone, so leaving them would keep stale content
|
|
// readable by the tools on later turns.
|
|
this.#syncMessageFiles()
|
|
if (!restoreToInput) return false
|
|
// An occupied composer declines the restore and keeps its own draft.
|
|
return this.aiChatInput?.restoreInstructions(instructions, pastes, images, files) === true
|
|
}
|
|
|
|
// Bytes each live composer has staged toward its next send (committed
|
|
// attachments + in-flight reads), keyed per composer instance. While a
|
|
// message is being edited the bottom composer and the edit box are both
|
|
// mounted; each must see the other's stage or two attaches could each spend
|
|
// the full conversation budget and overflow the persisted transcript.
|
|
#composerStaged = new SvelteMap<string, { editingIndex: number | null; bytes: number }>()
|
|
|
|
setComposerStaged(key: string, editingIndex: number | null, bytes: number) {
|
|
this.#composerStaged.set(key, { editingIndex, bytes })
|
|
}
|
|
|
|
clearComposerStaged(key: string) {
|
|
this.#composerStaged.delete(key)
|
|
}
|
|
|
|
/** Release the outgoing-files reservation identified by `key` (a per-send token).
|
|
* Called once when sendRequest installs the bubble (the transcript then accounts
|
|
* the files) and on every sendRequest path that exits before install — abandoning
|
|
* the send leaves the files in the composer/queue, which reserves them, so a
|
|
* stranded reservation would double-charge. Keyed per send so one send never
|
|
* releases a reservation another owns. */
|
|
#releaseOutgoingReservation(key: string | undefined) {
|
|
if (key) this.clearComposerStaged(key)
|
|
}
|
|
|
|
/** Attached-file bytes counted against MAX_CONVERSATION_FILE_BYTES by
|
|
* everything except composer `selfKey` (whose stage replaces its own edited
|
|
* message). A message ANOTHER composer is editing charges max(persisted,
|
|
* editor stage): a cancelled edit returns the persisted attachments. */
|
|
attachmentBytesExcluding(selfKey: string): number {
|
|
const selfEditing = this.#composerStaged.get(selfKey)?.editingIndex ?? null
|
|
const otherEdits = new Map<number, number>()
|
|
for (const [k, v] of this.#composerStaged) {
|
|
if (k !== selfKey && v.editingIndex !== null) otherEdits.set(v.editingIndex, v.bytes)
|
|
}
|
|
let total = 0
|
|
for (const [i, m] of this.displayMessages.entries()) {
|
|
if (i === selfEditing) continue
|
|
let persisted = 0
|
|
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
|
for (const f of m.files) persisted += textByteLength(f.content)
|
|
}
|
|
const editorStage = otherEdits.get(i)
|
|
total += editorStage !== undefined ? Math.max(persisted, editorStage) : persisted
|
|
}
|
|
for (const f of this.queuedFiles) total += textByteLength(f.content)
|
|
// Composers not tied to an edited message stage genuinely new bytes;
|
|
// editing composers were already accounted via the per-message max above.
|
|
for (const [k, v] of this.#composerStaged) {
|
|
if (k !== selfKey && v.editingIndex === null) total += v.bytes
|
|
}
|
|
return total
|
|
}
|
|
|
|
/** Reconcile the store's message-scoped file rows with what the transcript
|
|
* references, joined on the stable file id. Runs on chat load/clear, after
|
|
* rollbacks/truncations, and after compaction, so a chip the user can see is
|
|
* always readable and a dropped message's file never lingers in the tool
|
|
* surface. Also the single hydration point for transcripts persisted before
|
|
* ids existed: the id is a deterministic content hash, so legacy rows gain
|
|
* their permanent id here with no migration state. */
|
|
#syncMessageFiles = (): void => {
|
|
let hydrated = false
|
|
const withIds = this.displayMessages.map((m) => {
|
|
if ((m.role === 'user' || m.role === 'summary') && m.files?.some((f) => !f.id)) {
|
|
hydrated = true
|
|
return { ...m, files: withAttachedTextFileIds(m.files) }
|
|
}
|
|
return m
|
|
})
|
|
if (hydrated) this.displayMessages = withIds
|
|
const wanted = new Map<string, AttachedTextFile & { id: string }>()
|
|
for (const m of this.displayMessages) {
|
|
// Summary messages carry the files of the turns they folded away.
|
|
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
|
for (const f of m.files) wanted.set(f.id!, f as AttachedTextFile & { id: string })
|
|
}
|
|
}
|
|
try {
|
|
this.attachedFiles.syncMessageScoped([...wanted.values()])
|
|
} catch (e) {
|
|
console.error('Failed to sync message-attached files', e)
|
|
}
|
|
}
|
|
|
|
/** Ids of message-scoped files whose only referencing user messages were
|
|
* dropped from the API history by drop-oldest compaction (a negative `index`
|
|
* marks a message whose API counterpart is gone). Their `## ATTACHED FILES`
|
|
* reference no longer reaches the model — unlike summary compaction, which
|
|
* carries the reference on the summary — so the roster must advertise them.
|
|
* A file still referenced by a surviving message is not orphaned. */
|
|
orphanedMessageFileIds(): Set<string> {
|
|
const live = new Set<string>()
|
|
const dropped = new Set<string>()
|
|
for (const m of this.displayMessages) {
|
|
if ((m.role === 'user' || m.role === 'summary') && m.files) {
|
|
// A summary carries its files' reference in its own API message; that too
|
|
// can be dropped by a later drop-oldest (negative index), orphaning them.
|
|
const gone = m.index !== undefined && m.index < 0
|
|
for (const f of m.files) (gone ? dropped : live).add(f.id ?? f.name)
|
|
}
|
|
}
|
|
for (const n of live) dropped.delete(n)
|
|
return dropped
|
|
}
|
|
|
|
private notifyReasoningSummaryUnavailable = () => {
|
|
const provider = getCurrentModel().provider
|
|
const key = this.reasoningSummaryKey(provider)
|
|
if (!this.reasoningSummaryUnavailableFor.includes(key)) {
|
|
this.reasoningSummaryUnavailableFor = [...this.reasoningSummaryUnavailableFor, key]
|
|
}
|
|
if (getLocalSetting(REASONING_SUMMARY_WARNED_STORAGE_KEY) !== 'true') {
|
|
storeLocalSetting(REASONING_SUMMARY_WARNED_STORAGE_KEY, 'true')
|
|
sendUserToast(reasoningSummaryUnavailableMessage(provider), 'warning', [], undefined, 10000)
|
|
}
|
|
}
|
|
|
|
private chatRequest = async ({
|
|
messages,
|
|
abortController,
|
|
callbacks,
|
|
addedMessages,
|
|
systemMessage: systemMessageOverride,
|
|
onWebSearchUnavailable
|
|
}: {
|
|
messages: ChatCompletionMessageParam[]
|
|
abortController: AbortController
|
|
callbacks: ToolCallbacks & {
|
|
onNewToken: (token: string) => void
|
|
onMessageEnd: () => void
|
|
}
|
|
// Caller-owned accumulator so partial output survives an abort/throw.
|
|
addedMessages?: ChatCompletionMessageParam[]
|
|
systemMessage?: ChatCompletionSystemMessageParam
|
|
onWebSearchUnavailable?: () => void
|
|
}) => {
|
|
// Fresh batch for this turn — drop any images an aborted prior turn left buffered.
|
|
this.pendingToolImages.clear()
|
|
// Stale from a prior turn it would misattribute a pre-first-iteration failure.
|
|
this.lastIterationModel = undefined
|
|
const onReasoningSummaryUnavailable = () => this.notifyReasoningSummaryUnavailable()
|
|
try {
|
|
// Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider
|
|
// on each iteration. This is critical for changeModeTool (Navigator → Script/Flow)
|
|
// which reassigns this.tools, this.helpers, this.systemMessage mid-loop.
|
|
const self = this
|
|
// Pinned for the whole turn, like the `workspace` the loop routes through:
|
|
// the global chat's operating workspace follows workspaceStore, so a switch
|
|
// while a response streams would bill it to the workspace the user landed
|
|
// on rather than the one whose credentials and proxy served it.
|
|
const usageWorkspace = this.operatingWorkspace
|
|
const result = await runChatLoop({
|
|
messages,
|
|
addedMessages,
|
|
get systemMessage() {
|
|
let base = systemMessageOverride ?? self.systemMessage
|
|
// Inject the attached-files roster at request time (re-read each iteration)
|
|
// so it always reflects the live file list without reactive bookkeeping.
|
|
if (self.mode === AIMode.GLOBAL && self.attachedFiles.count > 0) {
|
|
base = appendAttachedFilesRoster(
|
|
base,
|
|
self.attachedFiles,
|
|
self.orphanedMessageFileIds()
|
|
)
|
|
}
|
|
base = self.planMode.decorateSystemMessage(base)
|
|
return base
|
|
},
|
|
get tools() {
|
|
return [...self.tools, ...self.planMode.tools]
|
|
},
|
|
get helpers() {
|
|
return self.helpers
|
|
},
|
|
abortController,
|
|
callbacks,
|
|
get modelProvider() {
|
|
return getCurrentModel()
|
|
},
|
|
get webSearch() {
|
|
return isWebSearchEnabledForProvider(getCurrentModel().provider)
|
|
},
|
|
// Build the proxy clients against the operating workspace, not the global
|
|
// singleton: a session deliberately leaves workspaceStore untouched, so the
|
|
// singleton (init'd only on global workspace changes) would route the LLM
|
|
// request through the navigation workspace's /ai/proxy instead of the
|
|
// session's — sending it to the wrong workspace's AI credentials.
|
|
get clients() {
|
|
const ws = self.operatingWorkspace ?? ''
|
|
return {
|
|
openai: workspaceAIClients.createOpenaiClient(ws),
|
|
anthropic: workspaceAIClients.createAnthropicClient(ws)
|
|
}
|
|
},
|
|
workspace: this.operatingWorkspace ?? '',
|
|
skipResponsesApi: this.skipResponsesApi,
|
|
onSkipResponsesApi: () => {
|
|
this.skipResponsesApi = true
|
|
},
|
|
onWebSearchUnavailable,
|
|
onReasoningSummaryUnavailable,
|
|
getPendingUserMessage: () => {
|
|
const pendingPrompt = this.pendingPrompt
|
|
if (!pendingPrompt) return undefined
|
|
this.pendingPrompt = ''
|
|
if (this.mode === AIMode.SCRIPT) {
|
|
return prepareScriptUserMessage(pendingPrompt, this.contextManager.getSelectedContext())
|
|
} else if (this.mode === AIMode.FLOW) {
|
|
return prepareFlowUserMessage(
|
|
pendingPrompt,
|
|
this.flowAiChatHelpers!.getFlowAndSelectedId(),
|
|
[],
|
|
this.flowAiChatHelpers!.inlineScriptSession
|
|
)
|
|
} else if (this.mode === AIMode.NAVIGATOR) {
|
|
return prepareNavigatorUserMessage(pendingPrompt)
|
|
} else if (this.mode === AIMode.GLOBAL) {
|
|
return prepareGlobalUserMessage(
|
|
pendingPrompt,
|
|
this.contextManager.getSelectedContext(),
|
|
{
|
|
workspace: this.operatingWorkspace,
|
|
activePreview: this.activePreviewResolver?.()
|
|
}
|
|
)
|
|
}
|
|
return undefined
|
|
},
|
|
onUsage: (usage, modelProvider) => {
|
|
// Accounting must never take a turn down with it.
|
|
try {
|
|
this.recordUsage(usage, modelProvider.provider, modelProvider.model, usageWorkspace)
|
|
} catch (e) {
|
|
console.error('Failed to record AI usage', e)
|
|
}
|
|
},
|
|
onBeforeIteration: async (tools, _helpers, modelProvider) => {
|
|
this.lastIterationModel = modelProvider
|
|
for (const tool of tools) {
|
|
if (tool.setSchema) {
|
|
await tool.setSchema(this.helpers)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) {
|
|
logFeatureUsage('ai_session', 'tokens', {
|
|
entityId: this.sessionId,
|
|
value: result.tokenUsage.total,
|
|
workspace: this.operatingWorkspace
|
|
})
|
|
}
|
|
return result
|
|
} catch (err) {
|
|
console.log('chatRequest error', err)
|
|
console.error('chatRequest error', err)
|
|
callbacks.onMessageEnd()
|
|
this.cancelLoadingTools('Error')
|
|
if (!abortController.signal.aborted) {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
sendInlineRequest = async (instructions: string, selectedCode: string, selection: Selection) => {
|
|
// Validate inputs
|
|
if (!instructions.trim()) {
|
|
throw new Error('Instructions are required')
|
|
}
|
|
// Use a separate abort controller for inline requests to avoid interfering with main chat
|
|
this.inlineAbortController = new AbortController()
|
|
const lang = this.scriptEditorOptions?.lang ?? 'bun'
|
|
const selectedContext: ContextElement[] = [...this.contextManager.getSelectedContext()]
|
|
const startLine = selection.startLineNumber
|
|
const endLine = selection.endLineNumber
|
|
selectedContext.push({
|
|
type: 'code_piece',
|
|
lang,
|
|
title: `L${startLine}-L${endLine}`,
|
|
startLine,
|
|
endLine,
|
|
content: selectedCode
|
|
})
|
|
|
|
const systemMessage: ChatCompletionSystemMessageParam = {
|
|
role: 'system',
|
|
content: prepareInlineChatSystemPrompt(lang, {
|
|
workflowAsCode: this.scriptEditorOptions?.workflowAsCode ?? false
|
|
})
|
|
}
|
|
|
|
let reply = ''
|
|
|
|
try {
|
|
const userMessage = prepareScriptUserMessage(instructions, selectedContext)
|
|
const messages = [userMessage]
|
|
|
|
const params = {
|
|
messages,
|
|
abortController: this.inlineAbortController,
|
|
callbacks: {
|
|
onNewToken: (token: string) => {
|
|
reply += token
|
|
},
|
|
onMessageEnd: () => {},
|
|
setToolStatus: () => {},
|
|
removeToolStatus: () => {}
|
|
},
|
|
systemMessage
|
|
}
|
|
|
|
await this.chatRequest({ ...params })
|
|
|
|
// Validate we received a response
|
|
if (!reply.trim()) {
|
|
throw new Error('AI response was empty')
|
|
}
|
|
|
|
// Try to extract new code from response
|
|
const newCodeMatch = reply.match(/<new_code>([\s\S]*?)<\/new_code>/i)
|
|
if (newCodeMatch && newCodeMatch[1]) {
|
|
const code = newCodeMatch[1].trim()
|
|
if (!code) {
|
|
throw new Error('AI response contained empty code block')
|
|
}
|
|
return code
|
|
}
|
|
|
|
// Fallback: try to take everything after the last <new_code> tag
|
|
const lastNewCodeMatch = reply.match(/<new_code>([\s\S]*)/i)
|
|
if (lastNewCodeMatch && lastNewCodeMatch[1]) {
|
|
const code = lastNewCodeMatch[1].trim().replace(/```/g, '')
|
|
if (!code) {
|
|
throw new Error('AI response contained empty code block')
|
|
}
|
|
return code
|
|
}
|
|
|
|
// If no code tags found, throw error with helpful message
|
|
throw new Error('AI response did not contain valid code. Please try rephrasing your request.')
|
|
} catch (error) {
|
|
// if abort controller is aborted, don't throw an error
|
|
if (this.inlineAbortController?.signal.aborted) {
|
|
return
|
|
}
|
|
console.error('Unexpected error in sendInlineRequest:', error)
|
|
throw new Error('An unexpected error occurred. Please try again.')
|
|
}
|
|
}
|
|
|
|
// Optional pre-flight hook called once per send, after the user's message
|
|
// bubble + loading indicator are shown optimistically but before the request
|
|
// goes 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
|
|
afterFirstTurnSaved?: () => Promise<void> | void
|
|
|
|
/** A send is between the composer clearing and its turn being installed.
|
|
* `loading` only rises after the attachment-upkeep awaits, so consumers that
|
|
* must not read half-installed history (ArrowUp recall) need this instead.
|
|
* Counted, not boolean: a send recursively flushes queued messages, and the
|
|
* inner one finishing doesn't mean the outer is done. */
|
|
#sendsInFlight = $state(0)
|
|
get sendInFlight(): boolean {
|
|
return this.#sendsInFlight > 0
|
|
}
|
|
|
|
sendRequest = async (options: Parameters<typeof this.sendRequestImpl>[0] = {}) => {
|
|
// A turn with nowhere to render still streams, spends tokens and applies
|
|
// tool calls — entirely off-screen. Refuse instead. `sendInlineRequest` is
|
|
// exempt: the ⌘K widget renders its own composer inside Monaco.
|
|
if (!this.isSessionChat && !chatState.dockedChatAvailable) {
|
|
console.error('sendRequest called with no chat UI mounted; dropping the turn')
|
|
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
|
|
return
|
|
}
|
|
// The workspace hid the assistant: every entry point is gone from the UI, so a turn
|
|
// reaching here comes from a path that missed the gate and would stream unseen.
|
|
if (!this.isSessionChat && get(copilotInfo).workspaceDisabled) {
|
|
sendUserToast('Windmill AI is hidden in this workspace.', true)
|
|
return
|
|
}
|
|
// Refused before anything mutates, so there is nothing to unwind: the
|
|
// draft (already taken by the composer) goes back where the user can see
|
|
// it, and the turn never starts. Only the message's own send restores it
|
|
// — a refused queued flush is re-queued by its caller (`accepted ===
|
|
// false`), and a copy here would double it. Paste tokens are expanded
|
|
// into the text, as the queue does, because the restore lanes carry no
|
|
// pastes.
|
|
if (this.runHeldElsewhere) {
|
|
if (options.synthetic) {
|
|
// Client-authored prompt (a job auto-resume), not user input: nothing
|
|
// to hand back and no toast. Releasing the staged text un-blocks the
|
|
// next auto-resume attempt, scheduled for when the hold clears.
|
|
this.instructions = ''
|
|
this.#scheduleAutoResumeRetry()
|
|
} else {
|
|
if (!options.queued) {
|
|
// Programmatic prompts (askAi, fix) stage their text in
|
|
// `this.instructions` and pass no option — fall back to it so
|
|
// they are handed back too.
|
|
this.restoreToInput(
|
|
expanded(chatDraft(options.instructions ?? this.instructions, options.pastes ?? [])),
|
|
options.images,
|
|
options.files
|
|
)
|
|
}
|
|
sendUserToast('This session is running in another tab. Your message was kept.', true)
|
|
}
|
|
return false
|
|
}
|
|
this.#sendsInFlight++
|
|
try {
|
|
return await this.sendRequestImpl(options)
|
|
} finally {
|
|
this.#sendsInFlight--
|
|
}
|
|
}
|
|
|
|
private sendRequestImpl = async (
|
|
options: {
|
|
removeDiff?: boolean
|
|
addBackCode?: boolean
|
|
instructions?: string
|
|
pastes?: PasteAttachment[]
|
|
images?: AttachedImage[]
|
|
files?: AttachedTextFile[]
|
|
mode?: AIMode
|
|
lang?: ScriptLang | 'bunnative'
|
|
isPreprocessor?: boolean
|
|
// Use this selected-context snapshot for the turn instead of the live
|
|
// contextManager. Set whenever a send settles its context ahead of the
|
|
// turn: a composer submit at the click, a queued message at enqueue.
|
|
contextOverride?: ContextElement[]
|
|
/** Where `contextOverride` came from. 'pinned' (default): the chips were
|
|
* selected for THIS message, so they are consumed from the live selection
|
|
* on send. 'replay': an edit/retry resending an older message's context —
|
|
* those chips were consumed long ago, and removing them again would strip
|
|
* an identical selection the user has since made in the composer. */
|
|
contextOverrideOrigin?: 'pinned' | 'replay'
|
|
/** Auto-send of a queued draft: on preflight failure the caller re-queues
|
|
* it, so the composer restore must not also fire (the draft would exist
|
|
* twice — queue chip and composer). */
|
|
queued?: boolean
|
|
/** Per-resend reservation token (see restartGeneration): the bytes staged
|
|
* under it are released once this send installs its bubble or exits before
|
|
* install. Absent on normal sends, so they never touch a resend's reservation. */
|
|
resendReservationKey?: string
|
|
/** This send was authored by the client (background-job auto-resume), not
|
|
* the user. Per-send, not read from #autoResuming: that flag stays up
|
|
* while this call recursively flushes queued messages, and those are real
|
|
* user turns. */
|
|
synthetic?: boolean
|
|
} = {}
|
|
) => {
|
|
// Returns whether the input was consumed: true when it was sent as a chat
|
|
// turn OR handled as a local built-in command, false when it was dropped
|
|
// without being acted on (mode hidden, empty non-GLOBAL draft, beforeSend
|
|
// failed). The queue flush restores the queued message only on false, so a
|
|
// consumed command isn't re-queued and re-fired into the next conversation.
|
|
//
|
|
// Reservation token for this send's outgoing files. A resend arrives with one
|
|
// already set by restartGeneration (it must reserve earlier, before its own
|
|
// pre-send slice); a normal/queued send mints one below, just before the
|
|
// attachment-upkeep awaits open a gap. Released once the bubble installs or the
|
|
// send exits before install. Kept in a mutable local so every exit path
|
|
// releases the right key.
|
|
let reservationKey = options.resendReservationKey
|
|
const requestedMode = options.mode ?? this.mode
|
|
if (!isAIModeVisible(requestedMode)) {
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
return false
|
|
}
|
|
this.changeMode(requestedMode, undefined, {
|
|
lang: options.lang,
|
|
isPreprocessor: options.isPreprocessor
|
|
})
|
|
// Explicitly-passed instructions win even when empty: an image-only send
|
|
// carries '' and must not inherit stale text a failed or cancelled earlier
|
|
// turn left in this.instructions.
|
|
if (options.instructions !== undefined) {
|
|
this.instructions = options.instructions
|
|
}
|
|
// A text-free GLOBAL draft is a real turn — rendered as its context chips
|
|
// (no bubble), with the empty-message marker substituted further down —
|
|
// but only when it carries something for the model: images, files, or
|
|
// selected context elements. A bare accidental Enter is dropped in every
|
|
// mode (in editor copilots it would burn a turn for nothing). Gate on
|
|
// requestedMode, not this.mode: changeMode can decline a switch (e.g.
|
|
// SCRIPT with no model), and a declined non-GLOBAL request must not slip
|
|
// through as a GLOBAL empty turn. Attachment-bearing non-GLOBAL drafts
|
|
// still pass through to the switch-back refusal below so attachments
|
|
// aren't silently lost.
|
|
if (
|
|
!this.instructions.trim() &&
|
|
(options.images?.length ?? 0) === 0 &&
|
|
(options.files?.length ?? 0) === 0
|
|
) {
|
|
const contextEls = options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
|
|
if (requestedMode !== AIMode.GLOBAL || contextEls.length === 0) {
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
return false
|
|
}
|
|
}
|
|
this.planMode.resetBlocks()
|
|
// Built-in session commands run locally instead of becoming a chat turn.
|
|
// Intercepted here — before the beforeSend workspace commit, file regrants,
|
|
// and skill expansion. Scoped to session chat GLOBAL mode, where the
|
|
// slash-command UI lives. Return true (consumed, not dropped) so that a
|
|
// command flushed from the queue isn't restored and re-fired into the next
|
|
// conversation.
|
|
if (this.isSessionChat && this.mode === AIMode.GLOBAL) {
|
|
const trimmed = this.instructions.trim()
|
|
// A local command consumes the send without installing a bubble; an edit
|
|
// resolved to `/clear` or `/compact` must not strand its resend reservation.
|
|
if (COMPACT_COMMAND_RE.test(trimmed) || CLEAR_COMMAND_RE.test(trimmed)) {
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
}
|
|
// `/compact`: summarize the conversation locally to free up context.
|
|
if (COMPACT_COMMAND_RE.test(trimmed)) {
|
|
this.instructions = ''
|
|
await this.compactManually()
|
|
return true
|
|
}
|
|
// `/clear`: save the conversation to history and start a fresh chat.
|
|
if (CLEAR_COMMAND_RE.test(trimmed)) {
|
|
this.instructions = ''
|
|
await this.saveAndClear()
|
|
return true
|
|
}
|
|
}
|
|
// Reserve the outgoing files' bytes now, before the upkeep awaits below: the
|
|
// composer (or queue) already cleared them, so without this reservation they
|
|
// are unaccounted during the gap and a fresh drop could spend the same
|
|
// headroom, overflowing the cap once this bubble lands. A resend already holds
|
|
// its own reservation (reused via reservationKey), so only mint for others.
|
|
if (!reservationKey && (options.files?.length ?? 0) > 0) {
|
|
reservationKey = `send:${createLongHash()}`
|
|
this.setComposerStaged(
|
|
reservationKey,
|
|
null,
|
|
options.files!.reduce((sum, f) => sum + textByteLength(f.content), 0)
|
|
)
|
|
}
|
|
// Re-grant any locked File System Access handles within this send gesture, so the
|
|
// file tools can read the live files. requestPermission() needs a user gesture, and
|
|
// this runs before the first await/network call while the Send click is still active.
|
|
// Attachment upkeep must never block the send — affected files just stay locked/stale
|
|
// and the tools report their status to the model.
|
|
try {
|
|
await this.attachedFiles.regrantLocked()
|
|
// Re-enumerate linked folders so on-disk changes (renamed/added/removed/edited
|
|
// files) are reflected in the roster + indexes before this turn runs.
|
|
await this.attachedFiles.refreshFolders()
|
|
} catch (e) {
|
|
console.error('Attached-files upkeep failed before send', e)
|
|
}
|
|
// beforeSend runs sequential API calls (session materialise + workspace fork
|
|
// creation) that can take seconds. Show the user bubble and loading indicator
|
|
// optimistically before it so the input doesn't just clear into a void.
|
|
// Context elements and the snapshot are attached after beforeSend (see below).
|
|
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
|
|
const pastes = options.pastes ?? []
|
|
// Attachments (images, text files) ride only on GLOBAL turns, but the
|
|
// composer stays mounted across a mode switch, so chips attached in GLOBAL
|
|
// can arrive with a send in any mode. Refuse and restore rather than
|
|
// silently dropping attachments the user can see. This sits past the
|
|
// awaits above on purpose: the composer clears itself synchronously right
|
|
// after calling sendRequest, so an earlier restore would be wiped. Queued
|
|
// drafts are the caller's to restore (it re-queues on false).
|
|
if (
|
|
((options.images?.length ?? 0) > 0 || (options.files?.length ?? 0) > 0) &&
|
|
this.mode !== AIMode.GLOBAL
|
|
) {
|
|
sendUserToast(
|
|
'Switch back to the chat mode to send attachments. Your message was kept.',
|
|
true
|
|
)
|
|
// Abandoned before install; the files go back to the composer, which
|
|
// re-reserves them, so release this send's outgoing-files reservation.
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
if (!options.queued) {
|
|
// Reached only once the mode has already moved off GLOBAL, so the
|
|
// restore is keyed to requestedMode: a GLOBAL submit whose mode
|
|
// flipped during the upkeep awaits above still gets its mentions
|
|
// back, while a send that started outside GLOBAL consumed none.
|
|
const taken =
|
|
this.aiChatInput?.restoreInstructions(
|
|
this.instructions,
|
|
pastes,
|
|
options.images ?? [],
|
|
options.files ?? []
|
|
) === true
|
|
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
|
}
|
|
return false
|
|
}
|
|
// Non-GLOBAL sends with images were refused above. The vision check is
|
|
// repeated here, not just at attach time: the model can be switched to a
|
|
// text-only one after attaching, and sending the image then fails the turn.
|
|
const requestedImages = options.images ?? []
|
|
// Text files pass regardless of vision support — the prompt carries only
|
|
// references; content is read via the file tools. Hydrated so every copy of
|
|
// this turn (bubble, prompt, registration, restore) carries the stable id —
|
|
// only edit/retry of a pre-id transcript can arrive without one, and the
|
|
// hash is deterministic, so hydration reproduces the original id.
|
|
const files = withAttachedTextFileIds(options.files ?? [])
|
|
const sendModel = tryGetCurrentModel()
|
|
const modelIsBlind = !!sendModel && !modelSupportsVision(sendModel.provider, sendModel.model)
|
|
if (requestedImages.length > 0 && modelIsBlind) {
|
|
// An image-only message has nothing left once the images are dropped —
|
|
// put them back in the composer instead of silently discarding them
|
|
// (the input already cleared itself optimistically on send). Queued
|
|
// drafts are the caller's to restore (it re-queues on false).
|
|
//
|
|
// No mention restore: an entry exists only while its `@` token is in the
|
|
// text (the picker adds both, the textarea's sync drops the entry when
|
|
// the token goes), and this branch requires empty text. A mention source
|
|
// that does not write a token would break that and need one here.
|
|
if (!this.instructions.trim() && files.length === 0) {
|
|
sendUserToast(`${sendModel.model} can't read images. Switch to a vision model first.`, true)
|
|
if (!options.queued) this.restoreToInput('', requestedImages)
|
|
return false
|
|
}
|
|
sendUserToast(
|
|
`${sendModel.model} can't read images; sending without the ${requestedImages.length} attached image(s).`,
|
|
true
|
|
)
|
|
}
|
|
const images = modelIsBlind ? [] : requestedImages
|
|
// Re-checks the wrapper's remote-run guard: a run announced by another tab
|
|
// during the upkeep awaits above would otherwise interleave two turns into
|
|
// one chat id. Resends are exempt — restartGeneration already truncated
|
|
// the transcript, so they run as the documented advisory race instead.
|
|
if (this.runHeldElsewhere && !options.resendReservationKey) {
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
if (options.synthetic) {
|
|
// Same as the wrapper guard: an internal prompt is released, not
|
|
// restored as a draft the user never wrote.
|
|
this.instructions = ''
|
|
this.#scheduleAutoResumeRetry()
|
|
} else {
|
|
// restoreToInput, not restoreInstructions: a draft typed during the
|
|
// awaits above occupies the composer, and this restore must merge
|
|
// into it (or queue), never be refused by it.
|
|
if (!options.queued) {
|
|
this.restoreToInput(expanded(chatDraft(this.instructions, pastes)), images, files)
|
|
}
|
|
sendUserToast('This session is running in another tab. Your message was kept.', true)
|
|
}
|
|
return false
|
|
}
|
|
const optimisticIndex = this.displayMessages.length
|
|
this.loading = true
|
|
// Create the abort controller before the (possibly slow) beforeSend pre-flight,
|
|
// not after: the loading indicator below exposes Stop/Escape during "Creating
|
|
// workspace fork...", and those call cancel() → abortController.abort(). Without a
|
|
// controller here that abort would hit nothing and the request would still fire
|
|
// once the pre-flight resolves; the pre-flight-cancel check after beforeSend honours it.
|
|
this.abortController = new AbortController()
|
|
this.displayMessages = [
|
|
...this.displayMessages,
|
|
{
|
|
role: 'user',
|
|
content: this.instructions,
|
|
pastes: pastes.length > 0 ? pastes : undefined,
|
|
// Same objects as the API message's parts: sharing the exact data URL
|
|
// lets the history's blob store persist one copy for both.
|
|
images: images.length > 0 ? images : undefined,
|
|
files: files.length > 0 ? files : undefined,
|
|
synthetic: options.synthetic ? true : undefined,
|
|
index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array
|
|
}
|
|
]
|
|
// The bubble now carries the outgoing files, so the transcript accounts them;
|
|
// release the reservation that bridged the preflight gap. A beforeSend
|
|
// rollback below restores them to the composer, which re-reserves.
|
|
this.#releaseOutgoingReservation(reservationKey)
|
|
// Undo the optimistic bubble + loading/label. Shared by the beforeSend-failure and
|
|
// pre-flight-cancel paths below; callers put the message back in the composer.
|
|
const rollbackOptimisticSend = () => {
|
|
this.displayMessages = this.displayMessages.filter((_, i) => i !== optimisticIndex)
|
|
this.loading = false
|
|
this.loadingLabel = undefined
|
|
}
|
|
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 put the message back in the composer (which cleared
|
|
// itself optimistically on send).
|
|
console.error('AIChatManager beforeSend hook failed', e)
|
|
rollbackOptimisticSend()
|
|
if (!options.queued) {
|
|
// Mentions were consumed at submit, so they come back with the text or
|
|
// not at all. Only when the composer took it: it declines when the
|
|
// user has started a new draft, and restoring then would put these
|
|
// mentions on that draft and every turn after it.
|
|
const taken =
|
|
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
|
|
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
|
}
|
|
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 false
|
|
}
|
|
}
|
|
// Session chats commit their workspace in beforeSend; the identity, skills and
|
|
// MCP servers must all match the committed workspace before the system prompt is
|
|
// sent. Settling them here rather than mid-turn also keeps the prompt — the
|
|
// cached prefix of every iteration — stable for the whole request.
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
await Promise.all([
|
|
this.refreshGlobalIdentity(this.operatingWorkspace ?? ''),
|
|
this.refreshGlobalSkills(this.operatingWorkspace ?? ''),
|
|
this.refreshMcpServers(this.operatingWorkspace ?? '')
|
|
])
|
|
}
|
|
// Stop/Escape during the beforeSend pre-flight aborted this send before any
|
|
// request went out. Mirror the main "cancelled before usable output" recovery:
|
|
// roll the optimistic turn back, then either hand off to a queued message (the
|
|
// input cleared the composer on send, so a deliberate cancel with a queued
|
|
// message auto-sends it) or restore this prompt to the composer so it isn't lost.
|
|
if (this.abortController.signal.aborted) {
|
|
rollbackOptimisticSend()
|
|
if (this.wasCancelledByUser() && this.#hasQueuedMessage()) {
|
|
const next = this.#takeQueue()
|
|
const accepted = await this.sendRequest({
|
|
instructions: next.draft.text,
|
|
images: next.draft.images,
|
|
files: next.draft.files,
|
|
contextOverride: next.context,
|
|
queued: true
|
|
})
|
|
if (accepted === false) this.#restoreQueue(next)
|
|
} else {
|
|
// Same pairing as the beforeSend catch above: mentions ride back with the
|
|
// text, only if the composer took it.
|
|
const taken =
|
|
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
|
|
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
|
}
|
|
return true
|
|
}
|
|
// Declared outside `try` so the catch can recover what the loop produced
|
|
// before a failure: the structured messages and the latest streamed text
|
|
// that never became one.
|
|
const collectedMessages: ChatCompletionMessageParam[] = []
|
|
let partialReply = ''
|
|
// Once an outcome branch (commit/restore) took over, a later throw (e.g.
|
|
// from saveChat) must not make the catch commit the turn a second time.
|
|
let turnOutcomeHandled = false
|
|
let webSearchUnavailable = false
|
|
// Gates the queued-message flush below: only a cleanly committed turn
|
|
// auto-sends the next queued message. Cancel, error, and empty-response
|
|
// rollbacks leave it false so queued text is restored to the input.
|
|
let turnCommittedCleanly = false
|
|
// A turn's output only reaches history when the turn ends, so a tab closed
|
|
// mid-turn loses every step it had taken. Persist progress WITHOUT
|
|
// committing it: the outcome branches still need `this.messages`
|
|
// unmodified to roll the turn back, and their save overwrites this.
|
|
let checkpointedShape = ''
|
|
const checkpointTurn = async (force = false) => {
|
|
// Text as received, not as painted: a hidden tab pauses the reveal loop
|
|
// while text keeps arriving, so fingerprinting painted text would stall
|
|
// the poll exactly when nobody is watching. `pending` reads it without
|
|
// disturbing the animation.
|
|
const streaming = this.currentReply + this.replyReveal.pending
|
|
// Write only when the turn advanced, so a parked confirmation costs
|
|
// nothing and the rate follows steps taken rather than time.
|
|
const shape = `${collectedMessages.length}:${this.displayMessages.length}:${streaming.length}`
|
|
if (!force && shape === checkpointedShape) return
|
|
// Live text only. Text the parsers have flushed is theirs to push, and
|
|
// they do so before the tool execution a checkpoint is likely to land in
|
|
// — so reading it here would mean re-appending what the transcript
|
|
// already holds. The abort path still recovers it via partialReply.
|
|
const { messages, keptPartialReply } = this.interruptedTurnMessages(
|
|
collectedMessages,
|
|
streaming,
|
|
{
|
|
interruptedToolContent: INTERRUPTED_TOOL_RESULT,
|
|
// A screenshot's image becomes a message only once its whole batch
|
|
// does, so a batch closed mid-flight would restore a result
|
|
// announcing a screenshot the model cannot see. Read without
|
|
// draining: the live turn still owns the buffer.
|
|
bufferedImages: pendingToolImagesMessage([...this.pendingToolImages.values()].flat())
|
|
}
|
|
)
|
|
if (messages.length === this.messages.length) return
|
|
checkpointedShape = shape
|
|
const { display, jobs } = this.#interruptedSnapshot()
|
|
// onMessageEnd is what gives streamed text its bubble, and it clears
|
|
// currentReply doing so — text still there has none, and without one the
|
|
// reply returns as context the reader cannot see.
|
|
const withStreamed =
|
|
streaming && keptPartialReply
|
|
? [...display, { role: 'assistant' as const, content: streaming }]
|
|
: display
|
|
// Best-effort: the turn-end save is the authoritative one, so a failed
|
|
// checkpoint must never break the turn it is only shadowing.
|
|
try {
|
|
await this.historyManager.saveChat(
|
|
withStreamed,
|
|
messages,
|
|
// No report describes this transcript: `contextUsage` still measures
|
|
// the pre-turn history while these messages already carry part of the
|
|
// turn. Storing it would under-report a restored chat by the whole
|
|
// partial turn — enough to skip the compaction its next send needs.
|
|
// Omitting drops the field, which is the "readers estimate" fallback.
|
|
undefined,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined,
|
|
jobs
|
|
)
|
|
} catch (e) {
|
|
console.error('Failed to checkpoint chat mid-turn', e)
|
|
}
|
|
}
|
|
const checkpointTimer = setInterval(() => void checkpointTurn(), CHECKPOINT_INTERVAL_MS)
|
|
// `hidden` precedes pagehide on close, reload and navigation and still runs a
|
|
// live document, so it is the last point a write can land. A navigation can
|
|
// outrun it — the poll, not this, carries the guarantee. `document` is absent
|
|
// under SSR and the node test env.
|
|
const hideTarget = typeof document !== 'undefined' ? document : undefined
|
|
const checkpointOnHide = () => {
|
|
if (hideTarget?.visibilityState === 'hidden') void checkpointTurn(true)
|
|
}
|
|
hideTarget?.addEventListener('visibilitychange', checkpointOnHide)
|
|
// Must stop when the loop hands back, not in `finally`: the outcome branches
|
|
// merge the turn into `this.messages` before awaiting their save, so a
|
|
// checkpoint there would re-append the same messages and, queued behind that
|
|
// save, persist the duplicate. Idempotent — every exit path calls it.
|
|
const stopCheckpoints = () => {
|
|
clearInterval(checkpointTimer)
|
|
hideTarget?.removeEventListener('visibilitychange', checkpointOnHide)
|
|
}
|
|
try {
|
|
// A pinned snapshot (a queued message, or a composer submit settling its
|
|
// context at the click) is used verbatim, leaving the live selection alone —
|
|
// it belongs to whatever the user has selected since. Otherwise read the
|
|
// current selection.
|
|
const oldSelectedContext =
|
|
options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
|
|
// DOM selector chips are one-shot: they ride with this message (captured in
|
|
// oldSelectedContext) and render above it, but must not persist in the input
|
|
// for the next turn. Clearing here leaves oldSelectedContext untouched.
|
|
if (options.contextOverrideOrigin === 'replay') {
|
|
// Edit/retry: the override is a copy of an already-sent message's
|
|
// context, consumed on its original send. The live selection belongs to
|
|
// the composer's own draft — touching it here would strip it.
|
|
} else if (options.contextOverride) {
|
|
// A pinned submit consumes only the chips it carried. Drop just those
|
|
// from the live selection (still there if the user didn't re-select); a
|
|
// newer selection made since is left intact.
|
|
for (const c of options.contextOverride) {
|
|
if (c.type === 'app_dom_selector') {
|
|
// Match appPath too: another app's live chip can share this
|
|
// selector, and dropping it would discard a newer selection.
|
|
this.contextManager?.removeSelectedDomElement(c.selector, c.appPath)
|
|
}
|
|
}
|
|
} else {
|
|
this.contextManager?.clearSelectedDomElements()
|
|
}
|
|
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
|
|
this.contextManager?.updateContextOnRequest(options)
|
|
}
|
|
// loading + abortController were set optimistically before beforeSend, above.
|
|
this.#automaticScroll = true
|
|
|
|
const model = tryGetCurrentModel()
|
|
if (model) {
|
|
const chatId = this.historyManager.getCurrentChatId()
|
|
logFeatureUsage('ai_chat', 'message', {
|
|
key: this.mode,
|
|
entityId: chatId,
|
|
workspace: this.operatingWorkspace
|
|
})
|
|
logFeatureUsage('ai_chat', 'model', {
|
|
key: `${model.provider}:${model.model}`,
|
|
entityId: chatId,
|
|
workspace: this.operatingWorkspace
|
|
})
|
|
}
|
|
if (this.isSessionChat && this.sessionId) {
|
|
logFeatureUsage('ai_session', 'message', {
|
|
key: this.mode,
|
|
entityId: this.sessionId,
|
|
workspace: this.operatingWorkspace
|
|
})
|
|
logFeatureUsage('ai_session', 'autonomy', {
|
|
key: this.autonomyMode,
|
|
entityId: this.sessionId,
|
|
workspace: this.operatingWorkspace
|
|
})
|
|
}
|
|
|
|
if (this.mode === AIMode.FLOW && !this.flowAiChatHelpers) {
|
|
throw new Error('No flow helpers found')
|
|
}
|
|
|
|
let snapshot:
|
|
| { type: 'flow'; value: ExtendedOpenFlow }
|
|
| { type: 'app'; value: number }
|
|
| undefined = undefined
|
|
if (this.mode === AIMode.FLOW) {
|
|
snapshot = { type: 'flow', value: this.flowAiChatHelpers!.getFlowAndSelectedId().flow }
|
|
this.flowAiChatHelpers!.setSnapshot(snapshot.value)
|
|
} else if (this.mode === AIMode.APP) {
|
|
snapshot = { type: 'app', value: this.appAiChatHelpers!.snapshot() }
|
|
}
|
|
|
|
// Attach the enrichments that are only known after beforeSend (selected
|
|
// context + snapshot) to the optimistic user message pushed before it.
|
|
this.displayMessages = this.displayMessages.map((m, i) =>
|
|
i === optimisticIndex
|
|
? {
|
|
...m,
|
|
contextElements:
|
|
this.mode === AIMode.SCRIPT ||
|
|
this.mode === AIMode.FLOW ||
|
|
this.mode === AIMode.GLOBAL
|
|
? oldSelectedContext
|
|
: undefined,
|
|
snapshot
|
|
}
|
|
: m
|
|
)
|
|
// For restoreUnsentTurn: the compact composer form (with paste tokens),
|
|
// not the expanded LLM text, plus the rollback anchor after the user turn.
|
|
const sentInstructions = this.instructions
|
|
const sentPastes = pastes
|
|
const sentImages = images
|
|
// The LLM gets the full pasted content; the display message above keeps
|
|
// the compact tokens + registry so the bubble can render/expand chips.
|
|
// A text-free send (and image-only sends carry their images as the
|
|
// content) gets an explicit model-facing marker: every mode's template
|
|
// interpolates the text under an INSTRUCTIONS header, and a dangling
|
|
// header confuses models into echoing it back verbatim.
|
|
const expandedInstructions = expanded(chatDraft(this.instructions, pastes))
|
|
const oldInstructions =
|
|
expandedInstructions.trim() || sentImages.length > 0
|
|
? expandedInstructions
|
|
: '(the user sent an empty message)'
|
|
// Deliver background-job completions to the model as a preamble on this
|
|
// turn (notify-only wake). Folded into the model-facing text only — the
|
|
// display bubble keeps this.instructions, and no extra message is added, so
|
|
// the display↔messages index pairing above stays intact. Ephemeral.
|
|
const jobNotesPreamble =
|
|
this.mode === AIMode.GLOBAL && this.pendingJobNotes.length > 0
|
|
? this.pendingJobNotes.join('\n\n') + '\n\n'
|
|
: ''
|
|
if (jobNotesPreamble) this.pendingJobNotes = []
|
|
const modelInstructions =
|
|
this.mode === AIMode.GLOBAL
|
|
? jobNotesPreamble + this.expandGlobalSkillCommand(oldInstructions)
|
|
: oldInstructions
|
|
this.instructions = ''
|
|
|
|
if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) {
|
|
throw new Error('No script options passed')
|
|
}
|
|
|
|
// Message-attached files travel as references: the prompt lists them by id
|
|
// and the model reads their content via the file tools. Register them in
|
|
// the store before the request goes out so this turn's reads can already
|
|
// see them. Registration failure must not block the send — the reference
|
|
// just reads as missing and the model reports it.
|
|
if (this.mode === AIMode.GLOBAL && files.length > 0) {
|
|
try {
|
|
this.attachedFiles.registerMessageFiles(files as (AttachedTextFile & { id: string })[])
|
|
} catch (e) {
|
|
console.error('Failed to register message-attached files', e)
|
|
}
|
|
}
|
|
|
|
let userMessage: ChatCompletionMessageParam = {
|
|
role: 'user',
|
|
content: ''
|
|
}
|
|
switch (this.mode) {
|
|
case AIMode.FLOW:
|
|
userMessage = prepareFlowUserMessage(
|
|
oldInstructions,
|
|
this.flowAiChatHelpers!.getFlowAndSelectedId(),
|
|
oldSelectedContext,
|
|
this.flowAiChatHelpers!.inlineScriptSession
|
|
)
|
|
break
|
|
case AIMode.NAVIGATOR:
|
|
userMessage = prepareNavigatorUserMessage(oldInstructions)
|
|
break
|
|
case AIMode.ASK:
|
|
userMessage = prepareAskUserMessage(oldInstructions)
|
|
break
|
|
case AIMode.SCRIPT:
|
|
userMessage = prepareScriptUserMessage(oldInstructions, oldSelectedContext)
|
|
break
|
|
case AIMode.API:
|
|
userMessage = prepareApiUserMessage(oldInstructions)
|
|
break
|
|
case AIMode.GLOBAL:
|
|
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
|
|
workspace: this.operatingWorkspace,
|
|
activePreview: this.activePreviewResolver?.(),
|
|
images: sentImages,
|
|
files: files
|
|
})
|
|
break
|
|
case AIMode.APP:
|
|
userMessage = prepareAppUserMessage(
|
|
oldInstructions,
|
|
this.appAiChatHelpers?.getSelectedContext(),
|
|
oldSelectedContext
|
|
)
|
|
break
|
|
}
|
|
|
|
// Size of the request about to go out: contextTokens (provider report
|
|
// when current, fresh chars/4 estimate otherwise) plus the message
|
|
// being added below. Must be read BEFORE the push — the estimate path
|
|
// covers the stored history, so pushing first would double-count the
|
|
// new message.
|
|
const projectedContextTokens = this.contextTokens + this.estimateMessagesTokens([userMessage])
|
|
|
|
this.messages.push(userMessage)
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
|
|
this.replyReveal.reset()
|
|
this.reasoningReveal.reset()
|
|
this.currentReply = ''
|
|
this.currentReasoning = ''
|
|
this.currentReasoningActive = false
|
|
this.resetReasoningTiming()
|
|
|
|
// Compaction trigger. An unrecognized model still gets the conservative
|
|
// assumed window rather than no limit: without one the context grows
|
|
// unbounded until the provider (or a proxy in front of it) times out.
|
|
// Guessing low only compacts earlier, which is always recoverable.
|
|
const contextWindow = model ? getModelContextWindow(model.model) : undefined
|
|
if (
|
|
contextWindow !== undefined &&
|
|
projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO
|
|
) {
|
|
// Preferred path: summarize the older prefix, keep the recent tail.
|
|
const summarized = await this.summarizeAndCompact(contextWindow)
|
|
// A Stop during the in-flight summary aborts this turn's controller;
|
|
// summarizeAndCompact then returns false without touching history. Skip
|
|
// the drop-oldest fallback (and its save) — it would destructively
|
|
// compact a conversation the user only meant to cancel, and the request
|
|
// can't run on an aborted controller anyway. The cancel path below rolls
|
|
// the pushed turn back cleanly on its own.
|
|
if (!this.abortController?.signal.aborted) {
|
|
if (!summarized) {
|
|
// Fallback when summarization isn't worthwhile or fails: drop the
|
|
// oldest messages. A report stays meaningful only debited by what
|
|
// was dropped; the estimate path needs no bookkeeping — the next
|
|
// read re-estimates the compacted history. chars/4 can
|
|
// underestimate the freed tokens, which errs toward compacting
|
|
// again — never toward overflowing.
|
|
const freed = this.compactOldestMessages(
|
|
projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO
|
|
)
|
|
if (this.contextUsage !== undefined) {
|
|
this.contextUsage = Math.max(0, this.contextUsage - freed)
|
|
}
|
|
}
|
|
// Reconcile file registrations with the compacted transcript — the
|
|
// summary message carries the folded-away turns' files forward.
|
|
this.#syncMessageFiles()
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
}
|
|
}
|
|
// Rollback anchors for restoreUnsentTurn: captured after compaction so
|
|
// they index into the (possibly compacted) stored history. The summary
|
|
// path shrinks displayMessages too, so the display anchor must also be
|
|
// read here, not before compaction.
|
|
const modelLenAfterUser = this.messages.length
|
|
const displayLenAfterUser = this.displayMessages.length
|
|
|
|
const params: {
|
|
messages: ChatCompletionMessageParam[]
|
|
abortController: AbortController
|
|
callbacks: ToolCallbacks & {
|
|
onNewToken: (token: string) => void
|
|
onMessageEnd: () => void
|
|
}
|
|
} = {
|
|
// The full history goes to the loop, image parts included, even on a
|
|
// known text-only model: runChatLoop strips them per iteration for
|
|
// whatever model that iteration runs on, so a mid-loop switch in either
|
|
// direction (vision→text or text→vision) sees the right view. A copy
|
|
// stripped here instead could never be un-stripped by a later iteration.
|
|
messages: [...this.messages],
|
|
abortController: this.abortController,
|
|
callbacks: {
|
|
onNewToken: (token) => {
|
|
this.markReasoningEnded()
|
|
this.replyReveal.push(token)
|
|
},
|
|
// Not every provider fires onReasoningStart, so deltas start the clock too.
|
|
onReasoningDelta: (token) => {
|
|
this.markReasoningStarted()
|
|
this.reasoningReveal.push(token)
|
|
},
|
|
onReasoningStart: () => {
|
|
this.markReasoningStarted()
|
|
this.currentReasoningActive = true
|
|
},
|
|
onMessageEnd: () => {
|
|
// Drain any un-revealed backlog into currentReply first, so the reads
|
|
// below see the full text. This funnel covers clean completion, tool
|
|
// boundaries, and abort/error — flush-before-read is the invariant that
|
|
// keeps text from being lost or duplicated on any exit path.
|
|
this.replyReveal.flush()
|
|
this.reasoningReveal.flush()
|
|
// A turn that reasoned straight into a tool call never saw an answer
|
|
// token, so this is where its thinking stops.
|
|
this.markReasoningEnded()
|
|
const reasoningDurationMs = this.takeReasoningDuration()
|
|
// Keep the streamed text for the abort/error paths. Non-empty only:
|
|
// parsers flush (and reset) when a tool call starts after text, and
|
|
// the catch's later empty call would wipe it — stale keeps are
|
|
// deduped in commitInterruptedTurn.
|
|
if (this.currentReply) {
|
|
partialReply = this.currentReply
|
|
}
|
|
if (this.currentReply || this.currentReasoning) {
|
|
this.displayMessages = [
|
|
...this.displayMessages,
|
|
{
|
|
role: 'assistant',
|
|
content: this.currentReply,
|
|
// Stamped as it lands. A chat restored from history predates this and
|
|
// simply shows no time rather than a made-up one.
|
|
createdAt: new Date().toISOString(),
|
|
...(this.currentReasoning
|
|
? { reasoning: this.currentReasoning, reasoningDurationMs }
|
|
: {}),
|
|
contextElements:
|
|
this.mode === AIMode.SCRIPT
|
|
? oldSelectedContext.filter((c) => c.type === 'code')
|
|
: undefined
|
|
}
|
|
]
|
|
}
|
|
this.currentReply = ''
|
|
this.currentReasoning = ''
|
|
this.currentReasoningActive = false
|
|
},
|
|
setToolStatus: this.applyToolStatus,
|
|
// Job-tracking hooks enable detach-into-background; wire them only in
|
|
// GLOBAL mode (global chat + sessions). In-editor script/flow/pipeline
|
|
// chats leave these undefined, so their test runs keep blocking.
|
|
...(this.mode === AIMode.GLOBAL
|
|
? {
|
|
onJobStarted: (job) => this.registerJob(job),
|
|
onJobStatus: (jobId, update) => this.updateJob(jobId, update),
|
|
onJobDetached: (jobId) => this.markJobDetached(jobId)
|
|
}
|
|
: {}),
|
|
removeToolStatus: (id) => {
|
|
const existingIdx = this.displayMessages.findIndex(
|
|
(m) => m.role === 'tool' && m.tool_call_id === id
|
|
)
|
|
if (existingIdx !== -1) {
|
|
this.displayMessages.splice(existingIdx, 1)
|
|
this.displayMessages = [...this.displayMessages]
|
|
}
|
|
},
|
|
requestConfirmation: this.requestConfirmation,
|
|
shouldAutoAcceptToolConfirmations: this.shouldAutoAcceptTool,
|
|
isPlanModeActive: () => this.planModeActive,
|
|
onToolBlockedByPlanMode: this.planMode.noteBlockedTool,
|
|
requestUserQuestion: this.requestUserQuestion,
|
|
requestRunArgs: this.requestRunArgs,
|
|
markRunFormStarted: this.markRunFormStarted,
|
|
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
|
|
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
|
|
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path),
|
|
attachToolImage: (toolId, image) => {
|
|
const existing = this.pendingToolImages.get(toolId) ?? []
|
|
this.pendingToolImages.set(toolId, [...existing, image])
|
|
},
|
|
takePendingToolImages: () => {
|
|
const images = [...this.pendingToolImages.values()].flat()
|
|
this.pendingToolImages.clear()
|
|
return images
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.mode === AIMode.API && this.apiTools.length === 0) {
|
|
await this.loadApiTools()
|
|
}
|
|
|
|
const result = await this.chatRequest({
|
|
...params,
|
|
addedMessages: collectedMessages,
|
|
onWebSearchUnavailable: () => {
|
|
webSearchUnavailable = true
|
|
}
|
|
})
|
|
stopCheckpoints()
|
|
const wasAborted = this.abortController?.signal.aborted ?? false
|
|
// Pure reasoning doesn't count as usable: it's not replayed as context,
|
|
// so a reasoning-only turn is as unsent as a literally empty one.
|
|
const hasUsableOutput =
|
|
truncateToToolPairedPrefix(collectedMessages).length > 0 || !!partialReply.trim()
|
|
turnOutcomeHandled = true
|
|
|
|
if (wasAborted && hasUsableOutput) {
|
|
// Interrupted after some output: keep it so a follow-up like
|
|
// "continue" picks up from there.
|
|
this.commitInterruptedTurn(collectedMessages, partialReply)
|
|
if (this.autoAcceptEditsActive) {
|
|
this.acceptPendingFlowEdits()
|
|
}
|
|
// The report from the last completed iteration still describes the
|
|
// stored history it was sent with (the kept partial tail is a small
|
|
// undercount the trigger headroom absorbs). Without one, clear the
|
|
// stale value — readers estimate via contextTokens.
|
|
this.contextUsage = result?.lastIterationUsage
|
|
? result.lastIterationUsage.prompt + result.lastIterationUsage.completion
|
|
: undefined
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
// Still counts as the saved first turn — skipping the hook here would
|
|
// permanently miss it (the next turn isn't "first" anymore).
|
|
if (isFirstUserTurn && this.afterFirstTurnSaved) {
|
|
void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => {
|
|
console.error('AIChatManager afterFirstTurnSaved hook failed', e)
|
|
})
|
|
}
|
|
} else if (wasAborted || !hasUsableOutput) {
|
|
// Cancelled before anything usable, or the model returned nothing
|
|
// (or only reasoning) — treat the turn as unsent (matches Claude Code).
|
|
// contextUsage is left as-is: the turn is rolled back, so the last
|
|
// report (pre-turn, possibly debited by compaction) still stands.
|
|
// When the user cancelled with a message queued, that message is
|
|
// about to auto-send (see the flush below) — drop the rolled-back
|
|
// prompt instead of restoring it to the input so the handoff is clean.
|
|
const willAutoSendQueued = this.wasCancelledByUser() && this.#hasQueuedMessage()
|
|
const textRestored = await this.restoreUnsentTurn(
|
|
displayLenAfterUser,
|
|
modelLenAfterUser,
|
|
sentInstructions,
|
|
sentPastes,
|
|
!willAutoSendQueued,
|
|
sentImages,
|
|
files
|
|
)
|
|
// restoreUnsentTurn hands the text/pastes/images back for a resend, but
|
|
// the DOM selector chips were already consumed from the live selection
|
|
// before the request went out. Restore this turn's own chips so the
|
|
// resend keeps its element scope — replacing (not merging) any chips
|
|
// selected during the stream, so the restored draft stays coherent.
|
|
// Only when the composer actually took the text back: on a queued-message
|
|
// handoff, or when a draft typed during the stream made the composer
|
|
// decline, this prompt is dropped — restoring its chips would then
|
|
// retarget whatever draft is sitting there.
|
|
if (textRestored) {
|
|
this.#restoreDomContext(oldSelectedContext)
|
|
this.#restoreMentionContext(oldSelectedContext, requestedMode)
|
|
}
|
|
if (this.displayMessages.length === 0) {
|
|
// saveChat no-ops on an empty transcript; the chat persisted earlier
|
|
// this turn would linger in history and resurface the rolled-back
|
|
// user message on reload. Remove it instead.
|
|
this.historyManager.deletePastChat(this.historyManager.getCurrentChatId())
|
|
} else {
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
}
|
|
if (!wasAborted) {
|
|
sendUserToast('The model returned no response — your message was restored to the input.')
|
|
}
|
|
} else {
|
|
// Clean turn with output → commit as-is.
|
|
this.messages = [...this.messages, ...collectedMessages]
|
|
// The provider's report describes the stored history exactly:
|
|
// compaction mutates it before sending, so what was sent IS what is
|
|
// stored — no anchoring or index bookkeeping needed. Without a
|
|
// report, clear the now-stale value — readers estimate via
|
|
// contextTokens.
|
|
this.contextUsage = result?.lastIterationUsage
|
|
? result.lastIterationUsage.prompt + result.lastIterationUsage.completion
|
|
: undefined
|
|
if (this.autoAcceptEditsActive) {
|
|
this.acceptPendingFlowEdits()
|
|
}
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
// Only this branch is a clean send: the queued-message flush below
|
|
// auto-sends the next message after it (set after saveChat so a
|
|
// persistence failure falls through to the restore path instead).
|
|
turnCommittedCleanly = true
|
|
if (isFirstUserTurn && this.afterFirstTurnSaved) {
|
|
void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => {
|
|
console.error('AIChatManager afterFirstTurnSaved hook failed', e)
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
stopCheckpoints()
|
|
console.error(err)
|
|
// Request failure: keep the usable output as context for a follow-up.
|
|
// Skipped when the throw came from post-outcome code (e.g. saveChat) —
|
|
// re-committing would duplicate the turn's messages.
|
|
if (!turnOutcomeHandled) {
|
|
this.commitInterruptedTurn(collectedMessages, partialReply)
|
|
// The turn is kept as context, images and all — but a provider that just
|
|
// refused an image would refuse it again on every later turn, wedging the
|
|
// conversation with no way out but editing the message or starting over.
|
|
// Drop the parts so the text still gets an answer; the bubbles keep their
|
|
// thumbnails, so the user can still see what they sent. Gated on the
|
|
// history, not this turn's attachments: the refused image can also be a
|
|
// screenshot follow-up or an earlier turn's upload (an unlisted text-only
|
|
// model gets the full history).
|
|
// The failing request is the last iteration's — the loop strips image
|
|
// parts per iteration, so that request carried them only if ITS model
|
|
// passed the vision gate. The send-time flag is only the fallback for a
|
|
// failure before the first iteration read the model (a turn can start on
|
|
// a known text-only model and switch mid-loop to an unlisted blind one).
|
|
const failingModel = this.lastIterationModel
|
|
const requestCarriedImages = failingModel
|
|
? modelSupportsVision(failingModel.provider, failingModel.model)
|
|
: !modelIsBlind
|
|
if (
|
|
requestCarriedImages &&
|
|
messagesHaveImageParts(this.messages) &&
|
|
isImageRejection(err, [
|
|
sendModel?.model,
|
|
tryGetCurrentModel()?.model,
|
|
failingModel?.model
|
|
])
|
|
) {
|
|
this.messages = stripImagePartsFromMessages(this.messages)
|
|
sendUserToast(
|
|
`${tryGetCurrentModel()?.model ?? 'The model'} could not read the attached image(s), so they were removed from the conversation. Your message was kept.`,
|
|
true
|
|
)
|
|
}
|
|
// Any prior report no longer describes the history (a partial turn
|
|
// was just committed); clear it so readers estimate instead. When
|
|
// the failure WAS a context-length error, that high estimate forces
|
|
// compaction on the next send instead of failing the same way again.
|
|
this.contextUsage = undefined
|
|
try {
|
|
await this.historyManager.saveChat(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
} catch (saveErr) {
|
|
console.error('Failed to persist partial chat after error', saveErr)
|
|
}
|
|
this.flagLastMessageAsError()
|
|
}
|
|
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
|
|
} finally {
|
|
this.loading = false
|
|
// Backstop for the paths that leave the try without reaching either call
|
|
// above (a pre-flight throw, an aborted compaction).
|
|
stopCheckpoints()
|
|
// Turn teardown: cancel any in-flight reveal frame and drop leftover
|
|
// backlog. onMessageEnd already flushed on every outcome, so this only
|
|
// releases the loop; it never discards uncommitted text.
|
|
this.replyReveal.reset()
|
|
this.reasoningReveal.reset()
|
|
// Refresh the free-tier usage meter after every turn (success or error), and
|
|
// let the turn that exhausts the grant flip to the exhausted state live.
|
|
void refreshFreeTierUsage(this.operatingWorkspace)
|
|
}
|
|
// Flush the queued message. Send it after a cleanly committed turn OR a
|
|
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
|
// to move on, so it sends automatically. A genuine error, an
|
|
// empty-response rollback, or a programmatic cancel (panel teardown,
|
|
// save-and-clear) leaves it in place as a card so it isn't fired into a
|
|
// failed or torn-down turn.
|
|
if ((turnCommittedCleanly || this.wasCancelledByUser()) && this.#hasQueuedMessage()) {
|
|
const next = this.#takeQueue()
|
|
const accepted = await this.sendRequest({
|
|
instructions: next.draft.text,
|
|
images: next.draft.images,
|
|
files: next.draft.files,
|
|
contextOverride: next.context,
|
|
queued: true
|
|
})
|
|
if (accepted === false) {
|
|
// The auto-send bailed before becoming a turn (e.g. beforeSend
|
|
// failed); keep it as the queued message instead of losing it.
|
|
this.#restoreQueue(next)
|
|
}
|
|
}
|
|
// A background job may have finished mid-turn: its note missed this turn's
|
|
// preamble (captured at the start) and the poller skipped auto-resume while
|
|
// we were loading. Now that we're idle, deliver it via an auto-resume. Skips
|
|
// itself if the queued-message flush above already carried the notes.
|
|
void this.#maybeAutoResumeFromJobs()
|
|
return true
|
|
}
|
|
|
|
// True when the current turn's controller was aborted by a deliberate user
|
|
// cancel (Esc / Stop), as opposed to a programmatic cancel (panel teardown,
|
|
// save-and-clear) or no abort at all. Gates the queued-message auto-send.
|
|
private wasCancelledByUser(): boolean {
|
|
const signal = this.abortController?.signal
|
|
return !!signal?.aborted && signal.reason === USER_CANCEL_REASON
|
|
}
|
|
|
|
cancel = (reason?: string) => {
|
|
for (const { resolve } of this.confirmationCallbacks.values()) {
|
|
resolve(false)
|
|
}
|
|
this.confirmationCallbacks.clear()
|
|
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
|
|
resolveQuestion(undefined)
|
|
}
|
|
this.userQuestionCallbacks.clear()
|
|
for (const [toolId, entry] of this.#runForms) {
|
|
entry.resolve?.(undefined)
|
|
// Stopping the turn is the form's other way out, and the draft dies with this loop:
|
|
// settledToolDisplay settles the card below without ever seeing what was typed.
|
|
this.#patchRunForm(toolId, {}, () => ({ parameters: this.#settledFormArgs(entry) }))
|
|
// The form settles with the turn, so a preview tab holding it goes too rather
|
|
// than being left on a form that can no longer run.
|
|
this.closeRunForm?.(toolId)
|
|
}
|
|
// Not through #settleRunForm: settledToolDisplay settles every card of the stopped
|
|
// turn at once, and it alone can tell a run that reached the server from one that
|
|
// never did.
|
|
this.#runForms.clear()
|
|
const cancelReason = reason ?? USER_CANCEL_REASON
|
|
console.log('cancelling request:', {
|
|
reason: cancelReason,
|
|
abortController: this.abortController
|
|
})
|
|
this.abortController?.abort(cancelReason)
|
|
this.cancelLoadingTools()
|
|
}
|
|
|
|
cancelInlineRequest = (reason?: string) => {
|
|
const cancelReason = reason ?? 'inline_cancelled'
|
|
console.log('cancelling inline request:', {
|
|
reason: cancelReason,
|
|
inlineAbortController: this.inlineAbortController
|
|
})
|
|
this.inlineAbortController?.abort(cancelReason)
|
|
}
|
|
|
|
/**
|
|
* The images of a stored user turn as the model saw them. Anything resending
|
|
* a turn (retry, edit) must read them from here, never from the transcript
|
|
* bubble: a provider rejection strips them from history while the bubble
|
|
* keeps its copy so the user can still see what they sent — resending that
|
|
* copy would re-attach the image the provider just refused.
|
|
*/
|
|
storedImages(displayMessageIndex: number): AttachedImage[] | undefined {
|
|
const shown = this.displayMessages[displayMessageIndex]
|
|
if (!shown || shown.role !== 'user') return undefined
|
|
// The wire format has no filename; recover it from the bubble's entry
|
|
// (same attachment order) so a retried/edited image keeps its name — the
|
|
// history title of an image-only chat derives from it.
|
|
return imagesFromContent(this.messages[shown.index]?.content)?.map((image, i) =>
|
|
shown.images?.[i]?.name ? { ...image, name: shown.images[i].name } : image
|
|
)
|
|
}
|
|
|
|
restartGeneration = async (
|
|
displayMessageIndex: number,
|
|
newContent?: string,
|
|
pastes?: PasteAttachment[],
|
|
images?: AttachedImage[],
|
|
editedContext?: ContextElement[],
|
|
files?: AttachedTextFile[]
|
|
) => {
|
|
const userMessage = this.displayMessages[displayMessageIndex]
|
|
|
|
if (!userMessage || userMessage.role !== 'user') {
|
|
throw new Error('No user message found at the specified index')
|
|
}
|
|
|
|
// Refused before anything mutates: past this point the transcript is
|
|
// sliced and resend bytes are reserved, and the sendRequest guard could
|
|
// only refuse AFTER that damage — restoring nothing, since this path
|
|
// carries its text in `this.instructions`, not the options. The retry and
|
|
// edit controls check only local `loading`, so a remote run reaches here.
|
|
// An edit (newContent defined, even '': attachment-only edits exist) is
|
|
// restored with its pastes expanded into the text; a bare retry mutates
|
|
// nothing yet, so there is nothing to restore. Un-submitted context-chip
|
|
// edits are the one loss — the chips re-seed from the untouched message
|
|
// on the next edit.
|
|
if (this.runHeldElsewhere) {
|
|
if (newContent !== undefined) {
|
|
this.restoreToInput(
|
|
expanded(chatDraft(newContent, pastes ?? [])),
|
|
images ?? [],
|
|
files ?? []
|
|
)
|
|
}
|
|
// "Text", not "message": chip edits are the part that does not survive.
|
|
sendUserToast('This session is running in another tab. Your text was kept.', true)
|
|
return
|
|
}
|
|
|
|
// Resolve the API restart point BEFORE reserving bytes or truncating: a
|
|
// stale index must fail while nothing has been mutated, or the transcript
|
|
// would be left truncated with the reservation leaked. A negative index
|
|
// marks a message whose API counterpart was removed by drop-oldest
|
|
// compaction — everything before it went too, so restarting from it
|
|
// restarts from an empty history.
|
|
const actualMessageIndex =
|
|
userMessage.index < 0 ? 0 : userMessage.index < this.messages.length ? userMessage.index : -1
|
|
if (actualMessageIndex === -1) {
|
|
throw new Error('No actual user message found to restart from')
|
|
}
|
|
|
|
// Read while both arrays are intact: storedImages pairs the API message with
|
|
// its transcript entry, and the truncations below drop them.
|
|
const sentImages = this.storedImages(displayMessageIndex)
|
|
|
|
// Reserve the resent files' bytes across the gap between the edit box
|
|
// unmounting and the optimistic message landing. A per-resend token owns the
|
|
// reservation (sendRequest releases only this key) so an unrelated or
|
|
// concurrent send never clears it. Set before the slice below removes the
|
|
// message from the transcript, so those bytes are always accounted.
|
|
const resendReservationKey = `resend:${createLongHash()}`
|
|
const resentFiles = files ?? userMessage.files ?? []
|
|
this.setComposerStaged(
|
|
resendReservationKey,
|
|
null,
|
|
resentFiles.reduce((sum, f) => sum + textByteLength(f.content), 0)
|
|
)
|
|
|
|
// Remove all messages including and after the specified user message
|
|
this.displayMessages = this.displayMessages.slice(0, displayMessageIndex)
|
|
this.messages = this.messages.slice(0, actualMessageIndex)
|
|
|
|
// The last report described the pre-rewind history; clear it. Readers
|
|
// fall back to estimating the rewound history (contextTokens), so the
|
|
// compaction trigger stays armed — e.g. for Retry after a context-length
|
|
// error, which rewinds through here.
|
|
this.contextUsage = undefined
|
|
|
|
// Resend with the message's context, not the live selection. DOM selector
|
|
// chips (and other context) are one-shot — cleared from the live selection
|
|
// after the first send — so reading the current selection would lose or swap
|
|
// the element the message was about. An edit passes `editedContext` (the edit
|
|
// box was seeded from this message's chips and the user may have changed
|
|
// them); a bare Retry passes nothing and falls back to the original
|
|
// contextElements. `undefined` for modes that don't attach context leaves the
|
|
// live-selection behavior. An empty array is a deliberate "no context".
|
|
this.instructions = newContent ?? userMessage.content
|
|
// Prune the truncated messages' file registrations BEFORE the resend
|
|
// re-registers its own — the other way around would delete the fresh rows.
|
|
this.#syncMessageFiles()
|
|
this.sendRequest({
|
|
pastes: pastes ?? userMessage.pastes,
|
|
contextOverride: editedContext ?? userMessage.contextElements,
|
|
contextOverrideOrigin: 'replay',
|
|
images: images ?? sentImages,
|
|
// The bubble copy is authoritative for files: the API message carries
|
|
// only a reference (content lives in the store), so nothing ever strips
|
|
// it the way providers strip image parts from history.
|
|
files: files ?? userMessage.files,
|
|
resendReservationKey
|
|
})
|
|
}
|
|
|
|
fix = () => {
|
|
if (!this.open) {
|
|
this.toggleOpen()
|
|
}
|
|
this.changeMode(AIMode.SCRIPT)
|
|
this.instructions = 'Fix the error'
|
|
this.contextManager?.setFixContext()
|
|
this.sendRequest()
|
|
}
|
|
|
|
addSelectedLinesToContext = (
|
|
lines: string,
|
|
startLine: number,
|
|
endLine: number,
|
|
moduleId?: string
|
|
) => {
|
|
if (!this.open) {
|
|
this.toggleOpen()
|
|
}
|
|
if (!moduleId) {
|
|
this.changeMode(AIMode.SCRIPT)
|
|
}
|
|
this.contextManager?.addSelectedLinesToContext(lines, startLine, endLine, moduleId)
|
|
this.focusInput()
|
|
}
|
|
|
|
saveAndClear = async () => {
|
|
this.cancel('saveAndClear')
|
|
// Drop any message queued in this conversation so it can't auto-send into
|
|
// the fresh chat or linger as a card across the switch.
|
|
this.#clearQueue()
|
|
// The tray + poller belong to the conversation being left; the just-saved
|
|
// chat keeps its persisted jobs (save() omits the arg → fallback preserves).
|
|
this.clearBackgroundJobs()
|
|
await this.historyManager.save(
|
|
this.displayMessages,
|
|
this.messages,
|
|
this.contextUsage,
|
|
this.modifiedItems ? [...this.modifiedItems] : undefined
|
|
)
|
|
this.displayMessages = []
|
|
this.messages = []
|
|
this.contextUsage = undefined
|
|
// The mask belongs to the conversation just saved — the fresh chat starts
|
|
// its own (empty) tracking; carrying entries over would claim the previous
|
|
// conversation's edits for the new one. Untracked chats stay untracked.
|
|
if (this.modifiedItems) this.modifiedItems = new SvelteSet()
|
|
// In an AI session, linked files are session-scoped: they persist across conversations
|
|
// (cleared only when the session is deleted). The ephemeral global side-panel chat has no
|
|
// session, so "New chat" must clear them — otherwise the next, unrelated conversation
|
|
// would still get the previous file roster and could read/search it.
|
|
if (!this.isSessionChat) this.attachedFiles.clear()
|
|
// Message-attached rows belong to the conversation just left in every case.
|
|
this.#syncMessageFiles()
|
|
this.syncArtifactsSession()
|
|
this.planMode.resetRound()
|
|
this.onChatRotated?.(this.historyManager.getCurrentChatId())
|
|
}
|
|
|
|
loadPastChat = async (id: string, { preserveQueue = false } = {}) => {
|
|
// A turn commits into whatever transcript it finds when it ends, so swapping
|
|
// one in underneath it misfiles the turn — or duplicates it, when the loaded
|
|
// chat already carries the turn's own checkpoint. Gated on `sendInFlight`
|
|
// too, for the pre-`loading` window `sendOrQueue` documents.
|
|
if (this.loading || this.sendInFlight) return
|
|
const chat = await this.historyManager.loadPastChat(id)
|
|
if (chat) {
|
|
// Drop any message queued in the current conversation so it doesn't
|
|
// auto-send into the loaded one or linger as a card across the switch.
|
|
// `preserveQueue` is for reloads that are NOT a switch — a cross-tab
|
|
// catch-up re-reading the conversation on screen — where the queued
|
|
// draft is unsent user input the reload must not destroy.
|
|
if (!preserveQueue) this.#clearQueue()
|
|
// Stop the poller for the conversation being left before swapping in the
|
|
// loaded chat's jobs below.
|
|
this.clearBackgroundJobs()
|
|
// Same isolation as saveAndClear: the ephemeral global chat's attachments belong to
|
|
// the conversation being left, not the one being loaded; sessions keep them.
|
|
if (!this.isSessionChat) this.attachedFiles.clear()
|
|
this.displayMessages = chat.displayMessages
|
|
this.messages = chat.actualMessages
|
|
this.contextUsage = normalizeContextUsage(chat.contextUsage)
|
|
// Seed the modified-items mask from the stored chat. A session's Edits
|
|
// surface is scoped strictly to what this session edited, so it must never
|
|
// fall back to showing every draft in the (possibly forked) workspace: a
|
|
// legacy chat with no stored mask seeds an empty tracked set, not undefined.
|
|
// The global side-panel chat never tracks, so leave it untouched there.
|
|
if (this.isSessionChat) {
|
|
const stored = this.historyManager.getModifiedItems(id)
|
|
this.modifiedItems = new SvelteSet(stored ?? [])
|
|
}
|
|
// Rebuild the jobs tray from the loaded chat, and re-attach the poller to
|
|
// any job that was still in flight when it was last persisted.
|
|
const storedJobs = this.historyManager.getBackgroundJobs(id)
|
|
this.backgroundJobs = storedJobs ? storedJobs.map((j) => ({ ...j })) : []
|
|
for (const j of this.backgroundJobs) {
|
|
if (this.isJobNonTerminal(j.status)) j.detached = true
|
|
}
|
|
if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs]
|
|
// Reloading resolves no card on its own. Settle every one the poller above
|
|
// will not reach, whoever wrote it — a record from a build that stored cards
|
|
// without their jobs would otherwise restore one that spins forever.
|
|
const pollable = this.#pollableToolCalls()
|
|
this.displayMessages = this.settledToolDisplay(
|
|
this.displayMessages,
|
|
'Interrupted',
|
|
(message) => !pollable.has(message.tool_call_id)
|
|
)
|
|
this.#ensureJobPoller()
|
|
// Message-attached files live in the transcript, not in the store's
|
|
// persistence — rebuild their rows so the loaded chat's references are
|
|
// readable (and the previous chat's are pruned).
|
|
this.#syncMessageFiles()
|
|
this.#automaticScroll = true
|
|
this.syncArtifactsSession()
|
|
this.planMode.resetRound()
|
|
this.onChatRotated?.(id)
|
|
}
|
|
}
|
|
|
|
private syncArtifactsSession = () => {
|
|
void this.artifacts.setSession(this.isSessionChat ? this.sessionId : undefined)
|
|
}
|
|
|
|
get automaticScroll() {
|
|
return this.#automaticScroll
|
|
}
|
|
|
|
disableAutomaticScroll = () => {
|
|
this.#automaticScroll = false
|
|
}
|
|
|
|
enableAutomaticScroll = () => {
|
|
this.#automaticScroll = true
|
|
}
|
|
|
|
generateStep = async (moduleId: string, lang: ScriptLang, instructions: string) => {
|
|
if (!this.flowAiChatHelpers) {
|
|
throw new Error('No flow helpers found')
|
|
}
|
|
this.flowAiChatHelpers.selectStep(moduleId)
|
|
await this.sendRequest({
|
|
instructions: instructions,
|
|
mode: AIMode.SCRIPT,
|
|
lang: lang,
|
|
isPreprocessor: moduleId === 'preprocessor'
|
|
})
|
|
}
|
|
|
|
listenForContextChange = (dbSchemas: DBSchemas, workspaceStore: string | undefined) => {
|
|
if (this.mode === AIMode.SCRIPT && this.scriptEditorOptions) {
|
|
this.contextManager.updateAvailableContext(
|
|
this.scriptEditorOptions,
|
|
dbSchemas,
|
|
workspaceStore ?? '',
|
|
true, // toolSupport: reasoning no longer disables DB/tool context
|
|
untrack(() => this.contextManager.getSelectedContext())
|
|
)
|
|
} else if (this.mode === AIMode.FLOW && this.flowOptions) {
|
|
this.contextManager.updateAvailableContextForFlow(
|
|
this.flowOptions,
|
|
dbSchemas,
|
|
workspaceStore ?? '',
|
|
true, // toolSupport: reasoning no longer disables DB/tool context
|
|
untrack(() => this.contextManager.getSelectedContext())
|
|
)
|
|
} else if (this.mode === AIMode.GLOBAL) {
|
|
this.contextManager.updateAvailableContextForGlobal(
|
|
workspaceStore ?? '',
|
|
untrack(() => this.contextManager.getSelectedContext())
|
|
)
|
|
}
|
|
|
|
if (this.scriptEditorOptions) {
|
|
this.contextManager.setScriptOptions(this.scriptEditorOptions)
|
|
}
|
|
}
|
|
|
|
listenForDbSchemasChanges = (dbSchemas: DBSchemas) => {
|
|
this.displayMessages = ContextManager.updateDisplayMessages(
|
|
untrack(() => this.displayMessages),
|
|
dbSchemas
|
|
)
|
|
}
|
|
|
|
listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => {
|
|
if (currentEditor && currentEditor.type === 'script') {
|
|
this.scriptEditorApplyCode = async (code, opts) => {
|
|
if (currentEditor && currentEditor.type === 'script') {
|
|
currentEditor.hideDiffMode()
|
|
await currentEditor.editor.reviewAndApplyCode(code, opts)
|
|
}
|
|
}
|
|
this.scriptEditorShowDiffMode = () => {
|
|
if (currentEditor && currentEditor.type === 'script') {
|
|
currentEditor.showDiffMode()
|
|
}
|
|
}
|
|
this.scriptEditorGetLintErrors = () => {
|
|
if (currentEditor && currentEditor.type === 'script') {
|
|
return currentEditor.editor.getLintErrors()
|
|
}
|
|
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
|
|
}
|
|
} else {
|
|
this.scriptEditorApplyCode = undefined
|
|
this.scriptEditorShowDiffMode = undefined
|
|
this.scriptEditorGetLintErrors = undefined
|
|
}
|
|
|
|
return () => {
|
|
this.scriptEditorApplyCode = undefined
|
|
this.scriptEditorShowDiffMode = undefined
|
|
this.scriptEditorGetLintErrors = undefined
|
|
}
|
|
}
|
|
|
|
listenForSelectedIdChanges = (
|
|
selectedId: string | undefined,
|
|
flowStore: ExtendedOpenFlow,
|
|
flowStateStore: FlowState,
|
|
currentEditor: CurrentEditor
|
|
) => {
|
|
function getModule(id: string) {
|
|
if (id === 'preprocessor') {
|
|
return flowStore.value.preprocessor_module
|
|
} else if (id === 'failure') {
|
|
return flowStore.value.failure_module
|
|
} else {
|
|
return dfs(id, flowStore, false)[0]
|
|
}
|
|
}
|
|
|
|
function getScriptOptions(id: string): ScriptOptions | undefined {
|
|
const module = getModule(id)
|
|
|
|
if (module && module.value.type === 'rawscript') {
|
|
const moduleState: FlowModuleState | undefined = flowStateStore[module.id]
|
|
|
|
const editorRelated =
|
|
currentEditor && currentEditor.type === 'script' && currentEditor.stepId === module.id
|
|
? {
|
|
diffMode: currentEditor.diffMode,
|
|
lastDeployedCode: currentEditor.lastDeployedCode,
|
|
lastSavedCode: undefined
|
|
}
|
|
: {
|
|
diffMode: false,
|
|
lastDeployedCode: undefined,
|
|
lastSavedCode: undefined
|
|
}
|
|
return {
|
|
args: moduleState?.previewArgs ?? {},
|
|
error:
|
|
moduleState && !moduleState.previewSuccess
|
|
? getStringError(moduleState.previewResult)
|
|
: undefined,
|
|
getCode: () => (module.value.type === 'rawscript' ? module.value.content : ''),
|
|
lang: module.value.language,
|
|
path: module.id,
|
|
...editorRelated
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
if (selectedId) {
|
|
const options = getScriptOptions(selectedId)
|
|
if (options) {
|
|
this.scriptEditorOptions = options
|
|
}
|
|
} else {
|
|
this.scriptEditorOptions = undefined
|
|
}
|
|
|
|
untrack(() =>
|
|
this.contextManager?.setSelectedModuleContext(
|
|
selectedId,
|
|
untrack(() => this.contextManager.getAvailableContext())
|
|
)
|
|
)
|
|
|
|
return () => {
|
|
this.scriptEditorOptions = undefined
|
|
}
|
|
}
|
|
|
|
setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => {
|
|
this.#flowEditors.add(flowHelpers)
|
|
// Only a chat that can reach FLOW mode names an editor (see `flowAiChatHelpers`).
|
|
if (!this.isSessionChat) {
|
|
this.flowAiChatHelpers = flowHelpers
|
|
}
|
|
untrack(() => {
|
|
if (this.autoAcceptEditsActive) {
|
|
this.acceptPendingFlowEdits(flowHelpers)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
this.#flowEditors.delete(flowHelpers)
|
|
if (!this.isSessionChat) {
|
|
this.flowAiChatHelpers = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
private flowEditorFor(storagePath: string): FlowAIChatHelpers | undefined {
|
|
return [...this.#flowEditors].find((helpers) => helpers.getStoragePath() === storagePath)
|
|
}
|
|
|
|
// Registered by the /pipeline editor while it is mounted. Rebuilds the global
|
|
// tool set so the pipeline tools appear (and disappear on unregister). Pipeline
|
|
// AI edits apply directly as drafts, so there is nothing to auto-accept.
|
|
// Returns a cleanup that tears the registration back down.
|
|
setPipelineHelpers = (pipelineHelpers: PipelineAIChatHelpers) => {
|
|
this.pipelineAiChatHelpers = pipelineHelpers
|
|
untrack(() => {
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
}
|
|
})
|
|
// The pipeline tools are now registered — release anything awaiting them.
|
|
const waiters = [...this.#pipelineHelpersWaiters]
|
|
this.#pipelineHelpersWaiters.clear()
|
|
waiters.forEach((resolve) => resolve())
|
|
|
|
return () => {
|
|
this.pipelineAiChatHelpers = undefined
|
|
untrack(() => {
|
|
if (this.mode === AIMode.GLOBAL) {
|
|
this.configureGlobalMode()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Await the pipeline editor's tool registration. Resolves `true` immediately
|
|
* when a pipeline editor is already mounted, or when the next one registers;
|
|
* resolves `false` after `timeoutMs` if none registers (e.g. a backgrounded
|
|
* session whose preview tab is not mounted, or a closed tab). Callers must
|
|
* treat `false` as "tools NOT available" rather than silently reporting
|
|
* success — the open_preview handler surfaces that to the model.
|
|
*/
|
|
waitForPipelineHelpers = (timeoutMs = 8000): Promise<boolean> => {
|
|
if (this.pipelineAiChatHelpers) return Promise.resolve(true)
|
|
return new Promise<boolean>((resolve) => {
|
|
let settled = false
|
|
const finish = (registered: boolean) => {
|
|
if (settled) return
|
|
settled = true
|
|
this.#pipelineHelpersWaiters.delete(onRegister)
|
|
resolve(registered)
|
|
}
|
|
const onRegister = () => finish(true)
|
|
this.#pipelineHelpersWaiters.add(onRegister)
|
|
setTimeout(() => finish(false), timeoutMs)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Refresh cached datatables from the app helpers (async)
|
|
* Creates one context element per table (not per datatable)
|
|
*/
|
|
refreshDatatables = async (): Promise<void> => {
|
|
if (!this.appAiChatHelpers) {
|
|
this.cachedDatatables = []
|
|
return
|
|
}
|
|
|
|
try {
|
|
const datatables = await this.appAiChatHelpers.listDatatableTables()
|
|
this.cachedDatatables = flattenDatatablesToAppContextElements(datatables)
|
|
} catch (err) {
|
|
console.error('Failed to refresh datatables:', err)
|
|
this.cachedDatatables = []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available context elements for app mode (frontend files + backend runnables + datatables)
|
|
*/
|
|
getAppAvailableContext = (): ContextElement[] => {
|
|
if (!this.appAiChatHelpers) {
|
|
return []
|
|
}
|
|
|
|
const context: ContextElement[] = []
|
|
|
|
// Add frontend files
|
|
const frontendFiles = this.appAiChatHelpers.listFrontendFiles()
|
|
for (const path of frontendFiles) {
|
|
const content = this.appAiChatHelpers.getFrontendFile(path)
|
|
if (content !== undefined) {
|
|
context.push(createAppFrontendFileContextElement(path, content))
|
|
}
|
|
}
|
|
|
|
// Add backend runnables
|
|
const runnables = this.appAiChatHelpers.listBackendRunnables()
|
|
for (const { key } of runnables) {
|
|
const runnable = this.appAiChatHelpers.getBackendRunnable(key)
|
|
if (runnable) {
|
|
context.push(createAppBackendRunnableContextElement(key, runnable))
|
|
}
|
|
}
|
|
|
|
// Add cached datatables
|
|
context.push(...this.cachedDatatables)
|
|
|
|
return context
|
|
}
|
|
|
|
setAppHelpers = (appHelpers: AppAIChatHelpers) => {
|
|
this.appAiChatHelpers = appHelpers
|
|
// Refresh datatables when app helpers are set (deferred to avoid loop)
|
|
// Use setTimeout to ensure this runs after the effect completes
|
|
if (this.appDatatablesRefreshTimeout) {
|
|
clearTimeout(this.appDatatablesRefreshTimeout)
|
|
}
|
|
this.appDatatablesRefreshTimeout = setTimeout(() => {
|
|
this.appDatatablesRefreshTimeout = undefined
|
|
if (this.appAiChatHelpers === appHelpers) {
|
|
void this.refreshDatatables()
|
|
}
|
|
}, 50)
|
|
|
|
return () => {
|
|
if (this.appDatatablesRefreshTimeout) {
|
|
clearTimeout(this.appDatatablesRefreshTimeout)
|
|
this.appDatatablesRefreshTimeout = undefined
|
|
}
|
|
if (this.appAiChatHelpers === appHelpers) {
|
|
this.appAiChatHelpers = undefined
|
|
this.cachedDatatables = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// In-flight and queued tool cards settled into a terminal state. Persisting
|
|
// one as-is restores a card that spins forever, and an unanswered question
|
|
// keeps the composer disabled (see isActiveUserQuestion) with nothing left
|
|
// running to answer it — so every transcript that outlives its turn goes
|
|
// through here first.
|
|
private settledToolDisplay = (
|
|
messages: DisplayMessage[],
|
|
messageText: string,
|
|
shouldSettle: (message: ToolDisplayMessage) => boolean = () => true
|
|
): DisplayMessage[] =>
|
|
messages.map((message) => {
|
|
if (
|
|
message.role === 'tool' &&
|
|
(message.isLoading || message.isQueued) &&
|
|
shouldSettle(message)
|
|
) {
|
|
// Stopping the turn does not stop the job, and between Run and the job's id
|
|
// there is no way to know whether the server queued one: nothing threads the
|
|
// abort into that request, so it lands either way. That window says so
|
|
// rather than picking a side — "canceled" hides a script that ran, "started"
|
|
// invents one that did not.
|
|
const runState = message.runForm?.started
|
|
? 'started'
|
|
: message.runForm?.submitted
|
|
? 'starting'
|
|
: 'idle'
|
|
return {
|
|
...message,
|
|
isLoading: false,
|
|
isQueued: false,
|
|
// Both render live affordances on their own, without consulting
|
|
// isLoading: a Run/Reject footer for a call nothing is waiting on,
|
|
// and a card that hides its result as still-streaming.
|
|
needsConfirmation: false,
|
|
isStreamingArguments: false,
|
|
// An interactive card disappears once canceled, so keep what it was
|
|
// asking readable in the collapsed header.
|
|
content: message.userQuestion
|
|
? `Asked: ${message.userQuestion.question} — ${messageText}`
|
|
: message.runForm
|
|
? runState === 'started'
|
|
? `Run ${message.runForm.path} — started, stopped tracking before it finished`
|
|
: runState === 'starting'
|
|
? `Run ${message.runForm.path} — ${messageText} while starting, check the runs page for a job`
|
|
: `Run ${message.runForm.path} — ${messageText}`
|
|
: messageText,
|
|
// A run that reached the server keeps whatever the job reported: it is not
|
|
// this turn's error, and the jobs tray is still following it.
|
|
...(runState === 'idle' ? { error: messageText } : {}),
|
|
userQuestion: message.userQuestion
|
|
? { ...message.userQuestion, canceled: true }
|
|
: undefined,
|
|
runForm: message.runForm
|
|
? settledRunForm({ ...message.runForm, canceled: runState === 'idle' })
|
|
: undefined
|
|
}
|
|
}
|
|
return message
|
|
})
|
|
|
|
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
|
|
this.displayMessages = this.settledToolDisplay(this.displayMessages, messageText)
|
|
}
|
|
|
|
/** What the transcript would be if the turn stopped here — for the writes that fire
|
|
* mid-turn without ending it. Loading is a property of this page: reloading resolves no
|
|
* card, so one stored still pending comes back asking for input nothing can deliver.
|
|
* Settles the stored copy only; the live turn keeps its cards.
|
|
*
|
|
* Except a card the poller will resolve after a reload: settling that one stores an
|
|
* "Interrupted" error the patch a completed job merges in carries nothing to clear.
|
|
* Which cards those are is loadPastChat's question, asked the same way — and the poller
|
|
* only knows the jobs stored in the same record, so both go into the same saveChat. */
|
|
#interruptedSnapshot = (): { display: DisplayMessage[]; jobs: ChatJob[] } => {
|
|
const polled = this.#pollableToolCalls()
|
|
return {
|
|
display: this.settledToolDisplay(
|
|
this.displayMessages,
|
|
'Interrupted',
|
|
(message) => !polled.has(message.tool_call_id)
|
|
),
|
|
jobs: $state.snapshot(this.backgroundJobs) as ChatJob[]
|
|
}
|
|
}
|
|
|
|
/** Tool calls a restored transcript can still resolve. loadPastChat re-attaches the
|
|
* poller to every non-terminal job and nothing else runs after a reload, so this is
|
|
* the whole set — asked identically when storing a card and when restoring one, or
|
|
* the two drift and a card is kept by one and stranded by the other. */
|
|
#pollableToolCalls = (): Set<string> =>
|
|
new Set(
|
|
this.backgroundJobs.filter((j) => this.isJobNonTerminal(j.status)).map((j) => j.toolCallId)
|
|
)
|
|
}
|
|
|
|
export const aiChatManager = new AIChatManager()
|
|
|
|
// The singleton is constructed at import — before the logged-in email resolves
|
|
// — so it starts at the safe autonomy default and an unopened chat-history DB.
|
|
// Hydrate both from user-scoped storage once the email is known, and on any
|
|
// later user change. Registered only here (not in the constructor) so
|
|
// per-session managers don't accumulate never-removed callbacks.
|
|
//
|
|
// init() is email-gated and idempotent, so re-opening the scoped DB here
|
|
// (alongside AiChatLayout's mount-time init()) is harmless and lets the
|
|
// singleton self-heal on email change like the other user-scoped surfaces.
|
|
onUserChange(() => {
|
|
aiChatManager.hydrateUserScopedAutonomy()
|
|
void aiChatManager.historyManager.init()
|
|
})
|