Files
windmill/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
T
e1e3692fbc feat: data table roles in the DB manager and raw apps (#11139)
* 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 commit 7dd3275a10.

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 commit 7dd3275a10.

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>
2026-09-21 12:03:01 +00:00

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()
})